27. Saving and Loading
[[programvideogames]]In this lesson we'll be creating save slots.
We can create new games and load a previous save.
To start out, we're going to store some extra state about the main menu in a new struct type to keep it together:
Main_Menu_State :: struct {
menu_type: Main_Menu_Type,
save_texture: rl.RenderTexture2D,
save_slots: [SAVE_SLOTS]Save_Data,
save_list_scroll_offset: f32,
}
Main_Menu_Type :: enum {
Default,
Select_Save_Slot,
}
// We also need a type that represents our saved data
Save_Data :: struct {
slot: int,
version: struct {
major, minor, patch: int,
},
seconds_played: f64,
level_iid: string, // Checkpoint level iid
location: string, // Most recent level name
checkpoint_iid: string,
collected_power_ups: bit_set[Power_Up_Type],
}
We're going to store one copy of save_data on Game_State that'll represent the current playthrough.
Game_State :: struct {
level_definitions: map[string]Level, // typo fix!
// ... other fields ...
main_menu_state: Main_Menu_State,
last_update_time: time.Time, // Used to update seconds played
save_data: Save_Data,
}
We'll also create a bunch of constants which we'll use shortly.
Move the hard-coded main menu background color into a constant, too.
BG_COLOR_MAIN_MENU :: rl.Color{0, 0, 28, 255}
// ...
SAVE_ITEM_HEIGHT :: 60
SAVE_SLOTS :: 10
SAVE_PANEL_WIDTH :: WINDOW_WIDTH / 2
SAVE_PANEL_HEIGHT :: WINDOW_HEIGHT / 3
SCROLL_SPEED :: 20
VERSION_MAJOR :: 0
VERSION_MINOR :: 1
VERSION_PATCH :: 0
Okay, now down in main_menu_update we'll clear using the new color:
rl.ClearBackground(BG_COLOR_MAIN_MENU)
Next, we're collapsing our "Continue" and "New Game" into one entry, "Play".
I changed "New Game" to "Settings", which we can fill in later.
There's a lot of code in this chunk, so I've put comments directly into the code:
main_menu_update :: proc(gs: ^Game_State) {
for !rl.WindowShouldClose() {
center := Vec2{WINDOW_WIDTH, WINDOW_HEIGHT} / 2
title_text: cstring = "METROIDVANIA"
title_text_size := rl.MeasureTextEx(gs.font_64, title_text, 64, 4)
rl.BeginDrawing()
rl.ClearBackground(BG_COLOR_MAIN_MENU)
// Draw Title
rl.DrawTextEx(
gs.font_64,
title_text,
{center.x - title_text_size.x / 2, center.y / 2},
64,
4,
rl.WHITE,
)
switch gs.main_menu_state.menu_type {
case .Default:
if main_menu_item_draw("Play", center) {
gs.main_menu_state.menu_type = .Select_Save_Slot
}
if main_menu_item_draw("Settings", center + {0, 60}) {
// TODO
// return
}
if main_menu_item_draw("Quit", center + {0, 120}) {
rl.CloseWindow()
return
}
case .Select_Save_Slot:
// To simulate a scrolling box, we are rendering to a different texture
target := gs.main_menu_state.save_texture
// This is the position of the scrolling box
panel_pos := Vec2{(WINDOW_WIDTH - SAVE_PANEL_WIDTH) / 2, WINDOW_HEIGHT / 2}
mouse_wheel_move := rl.GetMouseWheelMove()
// This offset is used to draw the correct part of the texture into the box
gs.main_menu_state.save_list_scroll_offset = clamp(
gs.main_menu_state.save_list_scroll_offset + mouse_wheel_move * SCROLL_SPEED,
SAVE_PANEL_HEIGHT - SAVE_ITEM_HEIGHT * SAVE_SLOTS,
0,
)
// Start drawing to the texture
rl.BeginTextureMode(target)
rl.ClearBackground(BG_COLOR_MAIN_MENU)
for save_data, i in gs.main_menu_state.save_slots {
// We always have SAVE_SLOTS amount of save slots
// So we check if the seconds_played is > 0 to determine if the slot is taken
if save_data.seconds_played > 0 {
// Save file exists, pass in location and seconds_played
if load_game_item_draw(
i,
panel_pos,
gs.main_menu_state.save_list_scroll_offset,
save_data.location,
save_data.seconds_played,
) {
gs.save_data = save_data
game_init(gs)
// Load level checkpoint is in
// If can't find - load at beginning of game
level_def := &gs.level_definitions[FIRST_LEVEL_IID]
// If we saved at a checkpoint, there should be a level_iid
if save_data.level_iid != "" {
level_def, _ = &gs.level_definitions[save_data.level_iid]
gs.checkpoint_level_iid = save_data.level_iid
gs.checkpoint_iid = save_data.checkpoint_iid
}
for checkpoint in level_def.checkpoints {
if checkpoint.iid == save_data.checkpoint_iid {
level_def.player_spawn = checkpoint.position
}
}
// Update the level_def to have the player_spawn position
gs.level_definitions[level_def.iid] = level_def^
gs.collected_power_ups = gs.save_data.collected_power_ups
level_load(gs, level_def)
}
} else {
// We are starting a new game
if load_game_item_draw(
i,
panel_pos,
gs.main_menu_state.save_list_scroll_offset,
) {
game_init(gs)
gs.save_data.slot = i
gs.save_data.version.major = VERSION_MAJOR
gs.save_data.version.minor = VERSION_MINOR
gs.save_data.version.patch = VERSION_PATCH
level_load(gs, &gs.level_definitions[FIRST_LEVEL_IID])
// Initial auto save
save_data_update(gs)
// seconds_played is f64 and will get truncated when turned into i64 duration
gs.save_data.seconds_played = 1
savefile_save(gs.save_data)
}
}
}
rl.EndTextureMode()
// We finished drawing to the scrolling box
// Now we draw the box onto the screen at the correct location
rl.DrawTextureRec(
gs.main_menu_state.save_texture.texture,
Rect {
0,
gs.main_menu_state.save_list_scroll_offset - SAVE_PANEL_HEIGHT,
SAVE_PANEL_WIDTH,
-SAVE_PANEL_HEIGHT,
},
panel_pos,
rl.WHITE,
)
{
text :: "Back"
size := rl.MeasureTextEx(gs.font_48, text, 48, 0)
if main_menu_item_draw(text, {32 + size.x / 2, WINDOW_HEIGHT - 60}) {
gs.main_menu_state.menu_type = .Default
}
}
}
rl.EndDrawing()
// gs.scene is set when we load_level
// We need to make sure we end texture and drawing modes
// Then we can return to change scene
if gs.scene != .Main_Menu {
return
}
}
}
Alright, next up is the load_game_item_draw proc that we just used.
Similar to the other UI procs, it returns true when clicked.
load_game_item_draw :: proc(
slot: int,
panel_pos: Vec2,
offset: f32,
location: string = "",
time_played: f64 = 0,
) -> (
pressed: bool,
) {
text: cstring
if time_played == 0 {
text = "New Game"
} else {
dur := time.Duration(i64(time_played * 1000 * 1000 * 1000))
buf: [time.MIN_HMS_LEN]u8
time_played_str := time.to_string_hms(dur, buf[:])
text = fmt.ctprintf("%d - %s, %s", slot + 1, location, time_played_str)
}
pos := Vec2{0, f32(slot) * SAVE_ITEM_HEIGHT}
mouse_pos := rl.GetMousePosition()
screen_pos := panel_pos + {0, pos.y + offset}
if rl.CheckCollisionPointRec(
mouse_pos,
{screen_pos.x, screen_pos.y, SAVE_PANEL_WIDTH, SAVE_ITEM_HEIGHT},
) {
rl.DrawTextEx(gs.font_48, text, pos, 48, 0, rl.YELLOW)
if rl.IsMouseButtonPressed(.LEFT) {
pressed = true
}
} else {
rl.DrawTextEx(gs.font_48, text, pos, 48, 0, rl.WHITE)
}
return
}
To make sure the time is stored correctly, we store the last_update_time and we initialise this at the start of game_init.
game_init :: proc(gs: ^Game_State) {
gs.last_update_time = time.now()
// ...
}
We'll also update it on each auto-save and at each use of a checkpoint.
We'll cover that shortly - first let's read the save files.
In main, just after setting up the cameras:
// Check for savefiles
{
curr_path := os.get_current_directory(context.temp_allocator)
save_dir_path := fmt.tprintf("%s/saves", curr_path)
save_dir, err := os.open(save_dir_path)
if err == .Not_Exist {
make_err := os.make_directory(save_dir_path)
save_dir, err = os.open(save_dir_path)
if make_err != nil || err != nil {
panic("Could not create save directory")
}
}
files, read_dir_err := os.read_dir(save_dir, 0, context.temp_allocator)
if read_dir_err != nil {
panic("Failed to read saves directory")
}
for file in files {
save_data, ok := savefile_load(file.fullpath)
if ok {
gs.main_menu_state.save_slots[save_data.slot] = save_data
}
}
}
// Setup Main Menu Texture
{
width :: SAVE_PANEL_WIDTH
height :: SAVE_SLOTS * SAVE_ITEM_HEIGHT
gs.main_menu_state.save_texture = rl.LoadRenderTexture(width, height)
}
We also set up the main menu render texture.
For the errors here, we panic because it's not a recoverable state.
If the player can't save their game, we don't want them to play.
That's everything we needed to do in main.odin.
Let's create a new file called savefile.odin that handles our saves.
package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:time"
savefile_save :: proc(save_data: Save_Data) -> (success: bool) {
options := json.Marshal_Options {
spec = .SJSON,
}
data, err := json.marshal(save_data, options, context.temp_allocator)
if err == nil {
path := fmt.tprintf("saves/%d.json", save_data.slot)
success = os.write_entire_file(path, data)
}
return
}
savefile_load :: proc(path: string) -> (save_data: Save_Data, ok: bool) {
data := os.read_entire_file(path) or_return
// Use permanent storage to save level and checkpoint iid
// At most, we waste iid * 2 number of bytes
if json.unmarshal(data, &save_data, .SJSON) == nil {
ok = true
}
return
}
// Not too sure where to put this
save_data_update :: proc(gs: ^Game_State) {
gs.save_data.collected_power_ups = gs.collected_power_ups
gs.save_data.location = gs.level.name
time_since_update := time.diff(gs.last_update_time, time.now())
gs.last_update_time = time.now()
gs.save_data.seconds_played += time.duration_seconds(time_since_update)
}
As you can see, the os and encoding/json packages are doing a lot of heavy lifting.
I've opted to use the .SJON format as it's a bit more compact.
Finally, we want to call these new procedures from our gameplay code.
Head over to player.odin and update the following.
First, when we change levels (using a door) we'll auto-save:
gs.level_definitions[level_def.iid] = level_def // Fixed typo
level_load(gs, &gs.level_definitions[door.to_level])
save_data_update(gs) // New
savefile_save(gs.save_data) // New
Finally, when we activate a checkpoint we'll explicitly update the save data:
try_activate_checkpoint :: proc(gs: ^Game_State, player: ^Entity) {
if rl.IsKeyPressed(.W) {
for checkpoint in gs.checkpoints {
rect := Rect{checkpoint.x, checkpoint.y, 32, 32}
if rl.CheckCollisionRecs(rect, player.collider) {
gs.checkpoint_level_iid = gs.level.iid
gs.checkpoint_iid = checkpoint.iid
// Below is new
gs.save_data.level_iid = gs.level.iid
gs.save_data.checkpoint_iid = checkpoint.iid
save_data_update(gs)
savefile_save(gs.save_data)
}
}
}
}
And that's it. We now have a working save/load system.
When we want to add more data, we can update the version (currently 0.1.0) and look at potential backwards compatibility.