6845 lines
		
	
	
		
			285 KiB
		
	
	
	
		
			C++
		
	
	
	
	
	
			
		
		
	
	
			6845 lines
		
	
	
		
			285 KiB
		
	
	
	
		
			C++
		
	
	
	
	
	
  /*@~@~@      |C|A|K|E|F|O|O|T| <presented by> 💫dank.game💫
 | 
						|
  |~)~)~)
 | 
						|
  |\~*~*|      Licensed under the zlib and CC-BY licenses. Source is available at
 | 
						|
  |\\~*~|
 | 
						|
,~|#\\~*|~,     <https://open.shampoo.ooo/shampoo/cakefoot>
 | 
						|
: \\@\\~| :
 | 
						|
:  \\#\\| :   Created with open SPACE🪐BOX engine for cross-platform, PC, web and mobile games
 | 
						|
:   \\@\' :
 | 
						|
:    \\/  :     <https://open.shampoo.ooo/shampoo/spacebox>
 | 
						|
`~ ~ ~`~ */
 | 
						|
 | 
						|
#if defined(__ANDROID__) || defined(ANDROID)
 | 
						|
#include <android/asset_manager_jni.h>
 | 
						|
#endif
 | 
						|
 | 
						|
#include "Cakefoot.hpp"
 | 
						|
 | 
						|
Cakefoot::Cakefoot(std::vector<nlohmann::json> configuration_merge) :
 | 
						|
 | 
						|
    /* Parse stats and achievements after the parent constructor runs */
 | 
						|
    sb::Game(configuration_merge), stats(configuration()), achievements(configuration())
 | 
						|
 | 
						|
{
 | 
						|
    sb::Log::log("Cakefoot version " + cakefoot::version);
 | 
						|
 | 
						|
#if defined(__ANDROID__)
 | 
						|
    SDL_SetHint(SDL_HINT_ORIENTATIONS, "Landscape");
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Merge the level JSON. If the levels file is missing or corrupt, the program will crash. */
 | 
						|
    configuration().merge(levels_file_path);
 | 
						|
 | 
						|
    /* Load scores */
 | 
						|
    fs::path arcade_scores_file_path {configuration()("storage", "scores file")};
 | 
						|
    if (fs::exists(arcade_scores_file_path))
 | 
						|
    {
 | 
						|
        /* Open and load the arcade scores, or pass with a warning if reading the scores fails. */
 | 
						|
        try
 | 
						|
        {
 | 
						|
            for (nlohmann::json score : sb::json_from_file(arcade_scores_file_path))
 | 
						|
            {
 | 
						|
                arcade_scores.add(ArcadeScores::Score(score.at("time"), score.at("distance"), score.at("name")));
 | 
						|
            }
 | 
						|
        }
 | 
						|
        catch (const std::exception& error)
 | 
						|
        {
 | 
						|
            std::ostringstream message;
 | 
						|
            message << "Couldn't read arcade scores in " << arcade_scores_file_path;
 | 
						|
            sb::Log::log(message, sb::Log::WARN);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Build string of unbeaten warped levels for default progress */
 | 
						|
    std::string default_warped_record;
 | 
						|
    for (std::size_t level_ii = 0; level_ii < configuration()("levels").size(); level_ii++)
 | 
						|
    {
 | 
						|
        default_warped_record += configuration()("warped", "level unbeaten").get<std::string>();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Create default progress data in-memory */
 | 
						|
    configuration()["progress"] = {
 | 
						|
        {"current level", 1},
 | 
						|
        {"max level", 1},
 | 
						|
        {"current difficulty", 0},
 | 
						|
        {"max difficulty", 0},
 | 
						|
        {"current challenge", 1},
 | 
						|
        {"current view", 0},
 | 
						|
        {"max view", 0},
 | 
						|
        {"total time", 0.0f},
 | 
						|
        {"quest level", 1},
 | 
						|
        {"quest checkpoint", 0.0f},
 | 
						|
        {"quest difficulty", 0},
 | 
						|
        {"quest time", 0.0f},
 | 
						|
        {"quest bank", 0},
 | 
						|
        {"quest best", 0.0},
 | 
						|
        {"quest deaths", 0},
 | 
						|
        {"arcade level", 1},
 | 
						|
        {"arcade checkpoint", 0.0f},
 | 
						|
        {"arcade difficulty", 0},
 | 
						|
        {"arcade max distance", 0},
 | 
						|
        {"arcade time", 0.0f},
 | 
						|
        {"arcade bank", 0},
 | 
						|
        {"arcade deaths", 0},
 | 
						|
        {"jackpot", 0},
 | 
						|
        {"all time bank", ""},
 | 
						|
        {"warped", default_warped_record}
 | 
						|
    };
 | 
						|
 | 
						|
    /* Replace in-memory progress data with data in the progress file if it is available */
 | 
						|
    fs::path progress_file_path {configuration()("storage", "progress file")};
 | 
						|
    if (fs::exists(progress_file_path))
 | 
						|
    {
 | 
						|
        try
 | 
						|
        {
 | 
						|
            nlohmann::json progress = sb::json_from_file(progress_file_path);
 | 
						|
 | 
						|
            /* The progress file contains a root element named "progress" which stores all the keys */
 | 
						|
            progress = progress.at("progress");
 | 
						|
 | 
						|
            /* Check progress data for existence of quest progress entries. If they are missing, it indicates an older
 | 
						|
             * progress file, so use the general progress values for the quest progress. */
 | 
						|
            if (!progress.contains("quest level"))
 | 
						|
            {
 | 
						|
                configuration()["progress"]["quest level"] = progress.value("current level", 1);
 | 
						|
                configuration()["progress"]["current challenge"] = 0;
 | 
						|
            }
 | 
						|
            if (!progress.contains("quest checkpoint"))
 | 
						|
            {
 | 
						|
                configuration()["progress"]["quest difficulty"] = progress.value("current difficulty", 0);
 | 
						|
            }
 | 
						|
            if (!progress.contains("quest time"))
 | 
						|
            {
 | 
						|
                configuration()["progress"]["quest time"] = progress.value("total time", 0.0f);
 | 
						|
            }
 | 
						|
 | 
						|
            /* Merge in the data from the file, overwriting the defaults set above. */
 | 
						|
            configuration().merge(progress_file_path);
 | 
						|
        }
 | 
						|
        catch (const std::exception& error)
 | 
						|
        {
 | 
						|
            sb::Log::log(
 | 
						|
                "Warning: an unreadable save file was detected at \"" + progress_file_path.string() +
 | 
						|
                "\". No save progress was able to be loaded. Check the file for corruption.", sb::Log::WARN);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Enforce arcade-only mode */
 | 
						|
    if (configuration()("arcade", "arcade only"))
 | 
						|
    {
 | 
						|
        configuration()["progress"]["current challenge"] = 4;
 | 
						|
        configuration()["progress"]["current difficulty"] = 0;
 | 
						|
 | 
						|
        /* In arcade-only, there is no resume, so reset the current level regardless of what the saved state was. */
 | 
						|
        configuration()["progress"]["current level"] = 1;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Load stat and achievement progress */
 | 
						|
    fs::path stat_file_path = configuration()("storage", "stats file");
 | 
						|
    if (!fs::exists(stat_file_path))
 | 
						|
    {
 | 
						|
        sb::Log::Multi() << "No existing stat file found at " << stat_file_path << ". If any stats or achievements " <<
 | 
						|
            " are stored, they will be written to a new file." << sb::Log::end;
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        try
 | 
						|
        {
 | 
						|
            stat_progress.load(stat_file_path);
 | 
						|
        }
 | 
						|
        catch (const std::exception& error)
 | 
						|
        {
 | 
						|
            sb::Log::Multi(sb::Log::WARN) << "Could not load stat and achievement file at " << stat_file_path <<
 | 
						|
                ". It will be overwritten by a new file if any stats or achievements are updated. Error: " <<
 | 
						|
                error.what();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Achievement for not having any achievements */
 | 
						|
    if (stat_progress.achievement_count() == 0)
 | 
						|
    {
 | 
						|
        stat_progress.unlock_achievement(achievements["ACH_EMPTY_STOMACH"]);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Check progress for stats that have not been transferred from the old progress format to the newer stats section
 | 
						|
     * of the progress object. Any associated achievements will be unlocked automatically. */
 | 
						|
    {
 | 
						|
        /* Award a quest count for confirmed quests complete */
 | 
						|
        int quests_awarded = 0;
 | 
						|
        if (progress<int>("max difficulty") > 0)
 | 
						|
        {
 | 
						|
            /* This only counts the first quest completed per difficulty level because there are no indicators for
 | 
						|
             * counting any other quests. */
 | 
						|
            quests_awarded = progress<int>("max difficulty") + (progress<int>("jackpot") == 777);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if deaths need to be transferred into the stats */
 | 
						|
        if (progress<nlohmann::json>("deaths", 0) > 0 && !stat_progress.stat_exists(stats["STAT_SLICER_DEATHS"]))
 | 
						|
        {
 | 
						|
            /* This is a very inaccurate estimate: it just divides the death count equally among each enemy type, and if
 | 
						|
             * it doesn't divide evenly into 4, it throws away a couple of deaths. */
 | 
						|
            int deaths_per_enemy = progress<int>("deaths") / 4;
 | 
						|
            stat_progress.set_stat(stats["STAT_SLICER_DEATHS"], deaths_per_enemy, achievements, stats);
 | 
						|
            stat_progress.set_stat(stats["STAT_FISH_DEATHS"], deaths_per_enemy, achievements, stats);
 | 
						|
            stat_progress.set_stat(stats["STAT_DRONE_DEATHS"], deaths_per_enemy, achievements, stats);
 | 
						|
            stat_progress.set_stat(stats["STAT_FIRE_DEATHS"], deaths_per_enemy, achievements, stats);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if distance needs to be awarded based on stats about play sessions. */
 | 
						|
        if (!stat_progress.stat_exists(stats["STAT_DISTANCE_TRAVELED"]) &&
 | 
						|
            (quests_awarded > 0 || arcade_scores.count() > 0 || progress<int>("quest level") > 1))
 | 
						|
        {
 | 
						|
            /* Award 20k for a complete quest */
 | 
						|
            stat_progress.increment_stat(stats["STAT_DISTANCE_TRAVELED"], quests_awarded * 20'000, achievements, stats);
 | 
						|
 | 
						|
            /* Award each distance from arcade with a 15% bonus for deaths and back pedaling */
 | 
						|
            stat_progress.increment_stat(
 | 
						|
                stats["STAT_DISTANCE_TRAVELED"], arcade_scores.total_distance(), achievements, stats);
 | 
						|
 | 
						|
            /* Award 800 for each quest level completed */
 | 
						|
            stat_progress.increment_stat(
 | 
						|
                stats["STAT_DISTANCE_TRAVELED"], (progress<int>("quest level") - 1) * 800, achievements, stats);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if arcade scores need to be transferred into the complete arcade runs stat */
 | 
						|
        if (arcade_scores.count() > 0 && !stat_progress.stat_exists(stats["STAT_ARCADE_RUNS"]))
 | 
						|
        {
 | 
						|
            stat_progress.set_stat(stats["STAT_ARCADE_RUNS"], arcade_scores.count(), achievements, stats);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if farthest distance needs to be transferred into the stats section */
 | 
						|
        if (progress<int>("arcade max distance") > 0)
 | 
						|
        {
 | 
						|
            if (!stat_progress.stat_exists(stats["STAT_FARTHEST_ARCADE_DISTANCE"]))
 | 
						|
            {
 | 
						|
                stat_progress.set_stat(
 | 
						|
                    stats["STAT_FARTHEST_ARCADE_DISTANCE"], progress<int>("arcade max distance"), achievements, stats);
 | 
						|
            }
 | 
						|
            if (!stat_progress.stat_exists(stats["STAT_FARTHEST_DISTANCE_REACHED"]))
 | 
						|
            {
 | 
						|
                stat_progress.set_stat(
 | 
						|
                    stats["STAT_FARTHEST_DISTANCE_REACHED"], progress<int>("arcade max distance"), achievements, stats);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if complete quests count should be estimated */
 | 
						|
        if (progress<int>("max difficulty") > 0)
 | 
						|
        {
 | 
						|
            if (!stat_progress.stat_exists(stats["STAT_QUESTS_COMPLETED"]))
 | 
						|
            {
 | 
						|
                stat_progress.set_stat(stats["STAT_QUESTS_COMPLETED"], quests_awarded, achievements, stats);
 | 
						|
            }
 | 
						|
            if (!stat_progress.stat_exists(stats["STAT_CAKES_UNLOCKED"]))
 | 
						|
            {
 | 
						|
                stat_progress.set_stat(stats["STAT_CAKES_UNLOCKED"], quests_awarded, achievements, stats);
 | 
						|
            }
 | 
						|
 | 
						|
            /* Set to the max, no need to check for an existing value */
 | 
						|
            stat_progress.set_stat(
 | 
						|
                stats["STAT_FARTHEST_DISTANCE_REACHED"], stats["STAT_FARTHEST_DISTANCE_REACHED"].max().value(),
 | 
						|
                achievements, stats);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if the quest time needs to be transferred */
 | 
						|
        if (progress<float>("quest best") > 0.0f && !stat_progress.stat_exists(stats["STAT_FASTEST_QUEST_TIME"]))
 | 
						|
        {
 | 
						|
            stat_progress.set_stat(stats["STAT_FASTEST_QUEST_TIME"], progress<float>("quest best"), achievements, stats);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if the fastest arcade time needs to be transferred. If so, ACH_HOTCAKES should also be unlocked. */
 | 
						|
        if (!stat_progress.stat_exists(stats["STAT_BEST_ARCADE_CLOCK"]))
 | 
						|
        {
 | 
						|
            if (arcade_scores.count() > 0 && arcade_scores.best().time > 0.0f)
 | 
						|
            {
 | 
						|
                stat_progress.set_stat(
 | 
						|
                    stats["STAT_BEST_ARCADE_CLOCK"], arcade_scores.best().time, achievements, stats);
 | 
						|
                stat_progress.unlock_achievement(achievements["ACH_HOTCAKES"]);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if levels need to be marked as unlocked */
 | 
						|
        if ((progress<int>("max difficulty") > 0 || progress<int>("max level") > 1) &&
 | 
						|
            !stat_progress.stat_exists(stats["STAT_LEVELS_UNLOCKED"]))
 | 
						|
        {
 | 
						|
            if (progress<int>("max difficulty") > 0)
 | 
						|
            {
 | 
						|
                /* All levels have been unlocked if BEEF CAKE is unlocked */
 | 
						|
                stat_progress.set_stat(
 | 
						|
                    stats["STAT_LEVELS_UNLOCKED"], configuration()("levels").size(), achievements, stats);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                /* Levels up to the max have been unlocked */
 | 
						|
                stat_progress.set_stat(
 | 
						|
                    stats["STAT_LEVELS_UNLOCKED"], progress<int>("max difficulty"), achievements, stats);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check if bank contents should be counted */
 | 
						|
        if (!stat_progress.stat_exists(stats["STAT_COINS_COLLECTED"]))
 | 
						|
        {
 | 
						|
            int collected = 0;
 | 
						|
            if (progress<nlohmann::json>("quest bank").is_string())
 | 
						|
            {
 | 
						|
                for (bool status : bank_parse(progress<std::string>("quest bank")))
 | 
						|
                {
 | 
						|
                    collected += status;
 | 
						|
                }
 | 
						|
            }
 | 
						|
            if (progress<nlohmann::json>("arcade bank").is_string())
 | 
						|
            {
 | 
						|
                for (bool status : bank_parse(progress<std::string>("arcade bank")))
 | 
						|
                {
 | 
						|
                    collected += status;
 | 
						|
                }
 | 
						|
            }
 | 
						|
            if (collected > 0)
 | 
						|
            {
 | 
						|
                stat_progress.set_stat(stats["STAT_COINS_COLLECTED"], collected, achievements, stats);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* If no coins are counted as unlocked, use the bank status or "max view" to unlock coins */
 | 
						|
        if (!stat_progress.stat_exists(stats["STAT_COINS_UNLOCKED"]))
 | 
						|
        {
 | 
						|
            std::vector<bool> bank;
 | 
						|
            if (progress<nlohmann::json>("quest bank").is_string())
 | 
						|
            {
 | 
						|
                bank = bank_parse(progress<std::string>("quest bank"));
 | 
						|
            }
 | 
						|
            if (progress<nlohmann::json>("arcade bank").is_string())
 | 
						|
            {
 | 
						|
                std::vector<bool> arcade_bank { bank_parse(progress<std::string>("arcade bank")) };
 | 
						|
                for (std::size_t bank_ii = 0; bank_ii < arcade_bank.size(); bank_ii++)
 | 
						|
                {
 | 
						|
                    if (bank.size() <= bank_ii)
 | 
						|
                    {
 | 
						|
                        bank.push_back(arcade_bank[bank_ii]);
 | 
						|
                    }
 | 
						|
                    else if (arcade_bank[bank_ii])
 | 
						|
                    {
 | 
						|
                        bank[bank_ii] = true;
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
            int unlocked = 0;
 | 
						|
            for (bool status : bank)
 | 
						|
            {
 | 
						|
                unlocked += status;
 | 
						|
            }
 | 
						|
            if (unlocked > 0)
 | 
						|
            {
 | 
						|
                stat_progress.set_stat(stats["STAT_COINS_UNLOCKED"], unlocked, achievements, stats);
 | 
						|
            }
 | 
						|
 | 
						|
            /* Save to all time bank tracker so the stat can update properly */
 | 
						|
            configuration()["progress"]["all time bank"] = bank_serialized(bank);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
#if defined(STEAM_ENABLED)
 | 
						|
    /* Run a ping test with Steam */
 | 
						|
    if (sb::cloud::steam::initialized())
 | 
						|
    {
 | 
						|
        sb::cloud::steam::store_stats();
 | 
						|
        std::int32_t deaths;
 | 
						|
        std::int32_t meters;
 | 
						|
        SteamAPI_ISteamUserStats_GetStatInt32(SteamUserStats(), stats["STAT_TOTAL_DEATHS"].id().c_str(), &deaths);
 | 
						|
        SteamAPI_ISteamUserStats_GetStatInt32(SteamUserStats(), stats["STAT_DISTANCE_TRAVELED"].id().c_str(), &meters);
 | 
						|
        sb::Log::Multi() << "Steam reports " << deaths << " total deaths in " << meters << " meters traveled" <<
 | 
						|
            sb::Log::end;
 | 
						|
    }
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Set the spinner values to what the player was last playing, unless demo mode is active, in which case leave
 | 
						|
     * the values at the defaults. */
 | 
						|
    if (!configuration()("demo", "active"))
 | 
						|
    {
 | 
						|
        level_select_index = configuration()("progress", "current level");
 | 
						|
        profile_index = configuration()("progress", "current difficulty");
 | 
						|
        challenge_index = configuration()("progress", "current challenge");
 | 
						|
        view_index = configuration()("progress", "current view");
 | 
						|
    }
 | 
						|
 | 
						|
    /* Initialize name entry */
 | 
						|
    name_entry = configuration()("display", "default initials");
 | 
						|
 | 
						|
    /* Initialize rotating hue highlight color */
 | 
						|
    rotating_hue = sb::Color(0.0f, 0.0f, 0.0f, 1.0f);
 | 
						|
    rotating_hue.hsv(
 | 
						|
        0.0f, configuration()("display", "highlight saturation"), configuration()("display", "highlight value"));
 | 
						|
 | 
						|
    /* Subscribe to events */
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_MOUSEMOTION);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_MOUSEBUTTONDOWN);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_MOUSEBUTTONUP);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_MOUSEWHEEL);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_JOYAXISMOTION);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_JOYHATMOTION);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_JOYBUTTONDOWN);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_JOYBUTTONUP);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_KEYDOWN);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_JOYDEVICEADDED);
 | 
						|
    delegate().subscribe(&Cakefoot::respond, this, SDL_JOYDEVICEREMOVED);
 | 
						|
 | 
						|
    /* Open a game controller if any are available at the beginning of the program */
 | 
						|
    open_game_controller();
 | 
						|
 | 
						|
    /* Set up playing field, the plane that provides the background of the curve, character, and enemies */
 | 
						|
    sb::Plane playing_field_plane;
 | 
						|
    playing_field_plane.scale(glm::vec3{_configuration("display", "playing field aspect"), 1.0f, 1.0f});
 | 
						|
    playing_field = sb::Sprite(playing_field_plane);
 | 
						|
 | 
						|
    /* Open the configuration and load the curve data per level */
 | 
						|
    load_curves();
 | 
						|
 | 
						|
    /* Link shaders and uniforms */
 | 
						|
    initialize_gl();
 | 
						|
 | 
						|
    /* Load and fill VBO */
 | 
						|
    load_vbo();
 | 
						|
 | 
						|
    /* Load cursors from the system library that will be freed automatically */
 | 
						|
    poke = std::shared_ptr<SDL_Cursor>(SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_HAND), SDL_FreeCursor);
 | 
						|
    grab = std::shared_ptr<SDL_Cursor>(SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_SIZEALL), SDL_FreeCursor);
 | 
						|
 | 
						|
    /* Set the character to use the profile stored to progress, and load the character's special event candle sprite */
 | 
						|
    character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
    _candle_lit.frame_length(configuration()("candle", "lit", "speed"));
 | 
						|
    _candle_lit.play();
 | 
						|
    _candle_lit.load();
 | 
						|
    _candle_unlit.load();
 | 
						|
 | 
						|
    /* Load the play button background graphics */
 | 
						|
    play_button_bg.frame_length(0.1f);
 | 
						|
    play_button_bg.play();
 | 
						|
    play_button_bg.load();
 | 
						|
 | 
						|
    /* Set up checkpoint on and off sprites */
 | 
						|
    checkpoint_on = sb::Sprite {"resource/checkpoint/on.png", glm::vec2(12.0f / 486.0f), GL_LINEAR};
 | 
						|
    checkpoint_off = sb::Sprite {"resource/checkpoint/off.png", glm::vec2(12.0f / 486.0f), GL_LINEAR};
 | 
						|
 | 
						|
    /* Set hitbox */
 | 
						|
    character.box_size(configuration()("character", "hitbox").get<float>());
 | 
						|
 | 
						|
    /* Load coin graphics */
 | 
						|
    coin.load();
 | 
						|
    coin_missing_texture.filter(GL_LINEAR);
 | 
						|
    coin_missing_texture.load();
 | 
						|
    coin_collected_texture.filter(GL_LINEAR);
 | 
						|
    coin_collected_texture.load();
 | 
						|
 | 
						|
    /* Load enemy graphics */
 | 
						|
    fish_sprite.texture("resource/fish/fish-1.png", GL_LINEAR);
 | 
						|
    fish_sprite.texture("resource/fish/fish-2.png", GL_LINEAR);
 | 
						|
    slicer_sprite.texture("resource/slicer/slicer-1.png", GL_LINEAR);
 | 
						|
    slicer_sprite.texture("resource/slicer/slicer-2.png", GL_LINEAR);
 | 
						|
    flame_sprite.texture("resource/flame/flame-1.png", GL_LINEAR);
 | 
						|
    flame_sprite.texture("resource/flame/flame-2.png", GL_LINEAR);
 | 
						|
    flame_coin_sprite.texture("resource/coin/coin-0.png", GL_LINEAR);
 | 
						|
    projectile_sprite.texture("resource/projectile/projectile-1.png", GL_LINEAR);
 | 
						|
    projectile_sprite.texture("resource/projectile/projectile-2.png", GL_LINEAR);
 | 
						|
    projectile_sprite.texture("resource/projectile/projectile-3.png", GL_LINEAR);
 | 
						|
    projectile_sprite.texture("resource/projectile/projectile-4.png", GL_LINEAR);
 | 
						|
    projector_sprite.texture("resource/projector/projector-1.png", GL_LINEAR);
 | 
						|
    projector_sprite.texture("resource/projector/projector-2.png", GL_LINEAR);
 | 
						|
 | 
						|
    /* Load HUD graphics */
 | 
						|
    auto_save_texture.filter(GL_LINEAR);
 | 
						|
    auto_save_texture.load();
 | 
						|
    qr_texture.filter(GL_LINEAR);
 | 
						|
    qr_texture.load();
 | 
						|
    qr_bg_texture.filter(GL_LINEAR);
 | 
						|
    qr_bg_texture.load();
 | 
						|
    demo_message_texture.filter(GL_LINEAR);
 | 
						|
    demo_message_texture.load();
 | 
						|
 | 
						|
    /* Load splash screens */
 | 
						|
    for (nlohmann::json splash_config : configuration()("display", "splash"))
 | 
						|
    {
 | 
						|
        splash.emplace_back(Splash {
 | 
						|
                sb::Sprite{splash_config.at(0).get<std::string>(), glm::vec2{1.77777f, 1.0f}, GL_LINEAR},
 | 
						|
                splash_config.at(1).get<glm::vec4>(),
 | 
						|
                splash_config.at(2)});
 | 
						|
    }
 | 
						|
 | 
						|
    /* Load SFX and BGM */
 | 
						|
    load_audio();
 | 
						|
 | 
						|
    /* Diagnostic text */
 | 
						|
    set_up_diagnostic_display();
 | 
						|
 | 
						|
    /* Store value from configuration when loading so it doesn't change while running and break the program */
 | 
						|
    use_play_button = configuration()("display", "use play button");
 | 
						|
 | 
						|
    /* Set to default values in case these get displayed erroneously */
 | 
						|
    label.at("arcade rank").content("999th");
 | 
						|
    label.at("arcade rank").refresh();
 | 
						|
    label.at("arcade distance").content("10000m");
 | 
						|
    label.at("arcade distance").refresh();
 | 
						|
 | 
						|
    /* Initialize scoreboard content */
 | 
						|
    refresh_scoreboard();
 | 
						|
 | 
						|
    /* Load progress menus */
 | 
						|
    load_achievements_menu();
 | 
						|
    load_stats_menu();
 | 
						|
    load_replays_menu();
 | 
						|
 | 
						|
    /* Start effect animations */
 | 
						|
    shift_hue_animation.frame_length(configuration()("display", "hue shift frequency"));
 | 
						|
    shift_hue_animation.play();
 | 
						|
    blink_animation.play();
 | 
						|
    warning_animation.play();
 | 
						|
    swap_title_shader_animation.frame_length(configuration()("shaders", "title swap frequency"));
 | 
						|
    swap_title_shader_animation.play();
 | 
						|
 | 
						|
    if (!use_play_button)
 | 
						|
    {
 | 
						|
        if (splash.size() > 0)
 | 
						|
        {
 | 
						|
            splash_index = 0;
 | 
						|
            splash_animation.play_once(splash[0].length);
 | 
						|
            world_color = splash[0].background.normal();
 | 
						|
            set_up_buttons();
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* Load title screen */
 | 
						|
            load_level(0);
 | 
						|
            set_up_buttons();
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        /* Just set up buttons because only play and volume buttons are needed */
 | 
						|
        set_up_buttons();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Bank HUD */
 | 
						|
    populate_bank_ui();
 | 
						|
 | 
						|
    /* Confirmation alert dialog */
 | 
						|
    load_confirmation_alert();
 | 
						|
 | 
						|
    /* Initialize sounds off by default, then switch on based on configuration using the appropriate buttons */
 | 
						|
    if (Mix_QuerySpec(nullptr, nullptr, nullptr) != 0)
 | 
						|
    {
 | 
						|
        Mix_Volume(-1, 0);
 | 
						|
        Mix_VolumeMusic(0);
 | 
						|
    }
 | 
						|
    for (auto& [name, chunk] : audio)
 | 
						|
    {
 | 
						|
        chunk.stop();
 | 
						|
        chunk.enabled(false);
 | 
						|
    }
 | 
						|
    bgm.stop();
 | 
						|
    bgm.enabled(false);
 | 
						|
    if (!configuration()("audio", "muted"))
 | 
						|
    {
 | 
						|
        button.at("volume").suppress_audio_once();
 | 
						|
        button.at("volume").press();
 | 
						|
    }
 | 
						|
    if (!configuration()("audio", "bgm muted"))
 | 
						|
    {
 | 
						|
        button.at("bgm").suppress_audio_once();
 | 
						|
        button.at("bgm").press();
 | 
						|
    }
 | 
						|
    if (!configuration()("audio", "sfx muted"))
 | 
						|
    {
 | 
						|
        button.at("sfx").suppress_audio_once();
 | 
						|
        button.at("sfx").press();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Track idle time */
 | 
						|
    idle_timer.on();
 | 
						|
 | 
						|
    /* Set timers to save and sync stats */
 | 
						|
    save_stats_animation.frame_length(configuration()("storage", "stats write frequency").get<float>());
 | 
						|
    save_stats_animation.play();
 | 
						|
    sync_stats_animation.frame_length(configuration()("storage", "steam stats sync frequency").get<float>());
 | 
						|
    sync_stats_animation.play();
 | 
						|
    sync_session_animation.frame_length(configuration()("session", "receiver", "frequency").get<float>());
 | 
						|
    sync_session_animation.play(sync_session_animation.frame_length());
 | 
						|
 | 
						|
    /* Needed because the minimum value for STAT_CONSECUTIVE_DAYS_PLAYED is 1 instead of 0. */
 | 
						|
    if (!stat_progress.stat_exists(stats["STAT_CONSECUTIVE_DAYS_PLAYED"]))
 | 
						|
    {
 | 
						|
        stat_progress.set_stat(stats["STAT_CONSECUTIVE_DAYS_PLAYED"], 1, achievements, stats);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Check for achievements and birthday candle graphics for special event days */
 | 
						|
    validate_date(std::chrono::system_clock::now());
 | 
						|
 | 
						|
    /* Save stats and achievements and sync with Steam in case any were updated */
 | 
						|
    stat_progress.save(configuration()("storage", "stats file"));
 | 
						|
 | 
						|
#if defined(EMSCRIPTEN)
 | 
						|
    /* Pause the game when the browser tab is hidden */
 | 
						|
    if (emscripten_set_visibilitychange_callback(this, false, &respond_to_visibility_change) < 0)
 | 
						|
    {
 | 
						|
        sb::Log::log("Failed to enable browser visibility change automatic pause feature", sb::Log::WARN);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Open the game controller when it is connected */
 | 
						|
    if (emscripten_set_gamepadconnected_callback(this, false, &respond_to_gamepad_connected) < 0)
 | 
						|
    {
 | 
						|
        sb::Log::log("Failed to listen for gamepad connections", sb::Log::WARN);
 | 
						|
    }
 | 
						|
#endif
 | 
						|
 | 
						|
#if defined(STEAM_ENABLED)
 | 
						|
    /* Make sure the locally stored achievements and stats are stored on Steam as well. */
 | 
						|
    stat_progress.sync_to_steam(achievements, stats);
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Automatic updates are only available on Linux */
 | 
						|
#if defined(__LINUX__)
 | 
						|
 | 
						|
    /* Make sure the configuration is set up for automatic updates before running. */
 | 
						|
    if (configuration()("system", "update", "automatic") &&
 | 
						|
        configuration()("system", "update").contains("location") &&
 | 
						|
        configuration()("system", "update").contains("info file") &&
 | 
						|
        configuration()("system", "update").contains("symlink") &&
 | 
						|
        configuration()("system", "update").contains("timeout") &&
 | 
						|
        configuration()("system", "update").contains("working directory"))
 | 
						|
    {
 | 
						|
        if (update_game_version())
 | 
						|
        {
 | 
						|
            sb::Log::Line() << "Quitting so new version can be loaded.";
 | 
						|
            flag_to_end();
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            float frequency { configuration()("system", "update").value("frequency", 0.0f) };
 | 
						|
            if (frequency != 0.0f)
 | 
						|
            {
 | 
						|
                /* Run update function on a timer to check for updates periodically. */
 | 
						|
                sb::Log::Multi() << "Checking for updates every " << frequency << " seconds." << sb::Log::end;
 | 
						|
                update_game_version_animation.frame_length(frequency);
 | 
						|
                update_game_version_animation.play(frequency);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                sb::Log::Line() << "No update frequency set, leaving auto update checks off.";
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
#endif
 | 
						|
}
 | 
						|
 | 
						|
#if defined(__LINUX__) && defined(HTTP_ENABLED)
 | 
						|
 | 
						|
bool Cakefoot::update_game_version()
 | 
						|
{
 | 
						|
    /* Return whether or not a new version was found and installed */
 | 
						|
    bool updated { false };
 | 
						|
 | 
						|
    /* Create a log to be sent to remote logger. */
 | 
						|
    std::string remote_report_log;
 | 
						|
 | 
						|
    /* Message to be logged at completion */
 | 
						|
    std::ostringstream update_result_log;
 | 
						|
 | 
						|
    /* Make sure none of the parameters have special characters. This prevents system commands from being inserted
 | 
						|
     * through the configuration. */
 | 
						|
    std::string invalid_characters { " `!@#$%^;\"'<>,?{}[]|\\&*()+=" };
 | 
						|
    std::string location { configuration()("system", "update", "location") };
 | 
						|
    std::string info_file { configuration()("system", "update", "info file") };
 | 
						|
    std::string symlink { configuration()("system", "update", "symlink") };
 | 
						|
    std::string working_directory { configuration()("system", "update", "working directory") };
 | 
						|
    int timeout { configuration()("system", "update", "timeout") };
 | 
						|
    if (strcspn(location.c_str(), invalid_characters.c_str()) == location.size() &&
 | 
						|
        strcspn(info_file.c_str(), invalid_characters.c_str()) == info_file.size() &&
 | 
						|
        strcspn(symlink.c_str(), invalid_characters.c_str()) == symlink.size() &&
 | 
						|
        strcspn(working_directory.c_str(), invalid_characters.c_str()) == working_directory.size())
 | 
						|
    {
 | 
						|
        /* Check if a command processor exists */
 | 
						|
        if (std::system(nullptr) > 0)
 | 
						|
        {
 | 
						|
            /* Build systemctl command */
 | 
						|
            std::ostringstream command;
 | 
						|
            command << "python3 " << configuration()("system", "update", "script") << " " << cakefoot::version << " " <<
 | 
						|
                location << " " << info_file << " " << symlink << " " << timeout << " " << working_directory;
 | 
						|
            remote_report_log += std::string("Command used: ") + command.str() + std::string(", ");
 | 
						|
 | 
						|
            /* To run successfully, this requires internet to be connected. */
 | 
						|
            sb::Log::Multi() << "Checking for updates at " << location << sb::Log::end;
 | 
						|
            int status = std::system(command.str().c_str());
 | 
						|
            if (WIFEXITED(status) == 0)
 | 
						|
            {
 | 
						|
                /* Report signal which stopped/ended the process */
 | 
						|
                if (WIFSIGNALED(status) == 0)
 | 
						|
                {
 | 
						|
                    if (WIFSTOPPED(status) > 0)
 | 
						|
                    {
 | 
						|
                        update_result_log << "Update stopped with signal " << WTERMSIG(status);
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    update_result_log << "Update ended with signal " << WTERMSIG(status);
 | 
						|
                }
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                /* Exit status 0 (success) means a full installation ran. */
 | 
						|
                if (WEXITSTATUS(status) == 0)
 | 
						|
                {
 | 
						|
                    update_result_log << "New version downloaded from " << location << ".";
 | 
						|
                    updated = true;
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    update_result_log << "Update exit status is " << WEXITSTATUS(status);
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            update_result_log << "No way to make system calls on this system.";
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        update_result_log << "One or more parameters contains invalid characters: " << location << ", " << info_file <<
 | 
						|
            ", " << symlink << ", " << working_directory;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Log results */
 | 
						|
    sb::Log::Multi() << update_result_log.str() << sb::Log::end;
 | 
						|
 | 
						|
    /* Send report to remote */
 | 
						|
    if (configuration()("system", "update").contains("logger"))
 | 
						|
    {
 | 
						|
        /* Send a single line to the remote logger. The logger can add additional information as necessary, such as the
 | 
						|
         * request IP address and a timestamp. */
 | 
						|
        remote_report_log += "Update result: " + update_result_log.str();
 | 
						|
 | 
						|
        /* Use an HTTP request to POST the log line as a JSON data field. Include user authentication if provided. */
 | 
						|
        const nlohmann::json& logger_config = configuration()("system", "update", "logger");
 | 
						|
        sb::cloud::HTTP::Request store_remote_log_request { logger_config.at("url") };
 | 
						|
        store_remote_log_request.authorization(logger_config.value("authorization", ""));
 | 
						|
        store_remote_log_request.data(nlohmann::json({"report", remote_report_log}));
 | 
						|
        store_remote_log_request.callback([&](const sb::cloud::HTTP::Request& request){
 | 
						|
            sb::Log::Multi() << "Sent arcade update log entry to remote logger. Received status " << request.status() <<
 | 
						|
                ". " << request.error() << sb::Log::end;
 | 
						|
        });
 | 
						|
        http.post(store_remote_log_request);
 | 
						|
    }
 | 
						|
 | 
						|
    return updated;
 | 
						|
}
 | 
						|
 | 
						|
#endif
 | 
						|
 | 
						|
void Cakefoot::open_game_controller()
 | 
						|
{
 | 
						|
    bool found = false;
 | 
						|
    for (int ii = 0, jj = 0; ii < SDL_NumJoysticks(); ii++)
 | 
						|
    {
 | 
						|
        if (SDL_IsGameController(ii))
 | 
						|
        {
 | 
						|
            std::ostringstream message;
 | 
						|
            message << "Gamepad #" << ++jj << ": ";
 | 
						|
            std::string name {SDL_GameControllerNameForIndex(ii)};
 | 
						|
            if (name == "")
 | 
						|
            {
 | 
						|
                name = "unnamed";
 | 
						|
            }
 | 
						|
            message << name;
 | 
						|
            if (controller.get() == nullptr)
 | 
						|
            {
 | 
						|
                controller = std::shared_ptr<SDL_GameController>(SDL_GameControllerOpen(ii), SDL_GameControllerClose);
 | 
						|
                if (controller.get() == nullptr)
 | 
						|
                {
 | 
						|
                    message << " [Could not open]";
 | 
						|
                    sb::Log::sdl_error();
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    found = true;
 | 
						|
                    message << " [Using this gamepad]";
 | 
						|
                    sb::Log::log(message);
 | 
						|
                    break;
 | 
						|
                }
 | 
						|
            }
 | 
						|
            sb::Log::log(message);
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            std::ostringstream message;
 | 
						|
            message << "Joystick #" << ii << " cannot be loaded by the game controller API";
 | 
						|
            sb::Log::log(message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
    if (!found)
 | 
						|
    {
 | 
						|
        sb::Log::log("No usable gamepad detected. Only mouse and touch controls will work.");
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_audio()
 | 
						|
{
 | 
						|
    audio = {};
 | 
						|
    for (const auto& [name, path] : configuration()("audio", "files").items())
 | 
						|
    {
 | 
						|
        sb::Log::Multi() << "Loading audio at " << path.get<std::string>() << sb::Log::end;
 | 
						|
        audio[name] = sb::audio::Chunk(path.get<std::string>());
 | 
						|
    }
 | 
						|
 | 
						|
    /* Set number of loops for looping audio */
 | 
						|
    audio.at("restart").loop(5);
 | 
						|
    audio.at("menu").loop();
 | 
						|
    audio.at("walk").loop();
 | 
						|
    audio.at("reverse").loop();
 | 
						|
 | 
						|
    /* Set volumes */
 | 
						|
    for (const auto& [name, volume] : configuration()("audio", "volume").items())
 | 
						|
    {
 | 
						|
        audio.at(name).volume(volume);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Reserve two channels for walk sound effects so the channel volume manipulation won't affect other sounds. */
 | 
						|
    int result = Mix_ReserveChannels(2);
 | 
						|
    if (result != 2)
 | 
						|
    {
 | 
						|
        sb::Log::sdl_error("Unable to reserve audio channels in SDL mixer", sb::Log::WARN);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Load the main theme and other BGM */
 | 
						|
    for (std::string chapter : configuration()("audio", "chapters"))
 | 
						|
    {
 | 
						|
        bgm.load(chapter);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_curves()
 | 
						|
{
 | 
						|
    /* Reset curve list and count of vertex buffer bytes needed for the curves */
 | 
						|
    curve_byte_count = 0;
 | 
						|
    curves.clear();
 | 
						|
 | 
						|
    /* Open the levels section of the configuration and iterate through the list of levels. */
 | 
						|
    nlohmann::json levels = configuration()["levels"];
 | 
						|
    for (std::size_t ii = 0; ii < levels.size(); ii++)
 | 
						|
    {
 | 
						|
        /* Get the current level curve points, which is a list of 2D vertices in the old format of 864x486. The vertices are control
 | 
						|
         * points for the bezier. */
 | 
						|
        nlohmann::json control = levels[ii]["curve"];
 | 
						|
        glm::vec2 orig = {864.0f, 486.0f}, point;
 | 
						|
        float rat = orig.x / orig.y;
 | 
						|
 | 
						|
        /* Translate each control point into the -aspect - aspect format. */
 | 
						|
        for (std::size_t jj = 0; jj < control.size(); jj++)
 | 
						|
        {
 | 
						|
            point = control[jj];
 | 
						|
            control[jj] = {(point.x / orig.x) * (2.0f * rat) - rat, (1.0f - point.y / orig.y) * 2.0f - 1.0f};
 | 
						|
        }
 | 
						|
 | 
						|
        /* For each group of four control points, create a bezier, and add each of its vertices to a vector containing all the
 | 
						|
         * non-wrapped vertices, which is the full curve for the current level before being wrapped. */
 | 
						|
        std::vector<glm::vec3> unwrapped;
 | 
						|
        for (std::size_t jj = 0; jj < control.size() - 2; jj += 3)
 | 
						|
        {
 | 
						|
            std::vector<glm::vec3> segment;
 | 
						|
            for (const glm::vec2& vertex :
 | 
						|
                     sb::math::bezier({control[jj], control[jj + 1], control[jj + 2], control[jj + 3]},
 | 
						|
                                      _configuration("curve", "bezier resolution").get<int>()))
 | 
						|
            {
 | 
						|
                segment.push_back({vertex, 0.0f});
 | 
						|
            }
 | 
						|
            unwrapped.insert(unwrapped.end(), segment.begin(), segment.end());
 | 
						|
        }
 | 
						|
 | 
						|
        /* Pass the vertices to a curve object, which will store the originals, then wrap them. */
 | 
						|
        Curve curve {rat};
 | 
						|
        curve.add(unwrapped);
 | 
						|
        curves.push_back(curve);
 | 
						|
        curve_byte_count += curve.size();
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::initialize_gl()
 | 
						|
{
 | 
						|
    /* Generate a vertex array object ID, bind it as current (requirement of OpenGL) */
 | 
						|
    vao.generate();
 | 
						|
    sb::Log::gl_errors("after generating VAO");
 | 
						|
    vao.bind();
 | 
						|
    sb::Log::gl_errors("after binding VAO");
 | 
						|
 | 
						|
    /* Load shaders and start using one */
 | 
						|
    load_shaders();
 | 
						|
    use_shader_program(configuration()("shaders", "order", 0));
 | 
						|
 | 
						|
    /* Set the active texture once at context load time because only one texture is used per draw */
 | 
						|
    glActiveTexture(GL_TEXTURE0);
 | 
						|
 | 
						|
    /* Enable alpha rendering, multisample buffer (anti-aliasing), disable depth test, set clear color */
 | 
						|
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
 | 
						|
    glEnable(GL_BLEND);
 | 
						|
    glEnable(GL_MULTISAMPLE);
 | 
						|
    glDisable(GL_DEPTH_TEST);
 | 
						|
    glm::vec4 clear_color = _configuration("display", "clear color").get<glm::vec4>();
 | 
						|
    glClearColor(clear_color[0], clear_color[1], clear_color[2], clear_color[3]);
 | 
						|
    sb::Log::gl_errors("after GL initialization");
 | 
						|
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_shaders()
 | 
						|
{
 | 
						|
    /* Free up space by deleting any existing shader programs */
 | 
						|
    for (const auto& [name, program] : shader_programs)
 | 
						|
    {
 | 
						|
        /* Get the IDs of the shaders attached to this program, along with a count. */
 | 
						|
        int shader_count = 0;
 | 
						|
        GLuint shader_ids[16];
 | 
						|
        glGetAttachedShaders(program, 16, &shader_count, shader_ids);
 | 
						|
 | 
						|
        /* Delete each attached shader */
 | 
						|
        for (int index = 0; index < shader_count; index++)
 | 
						|
        {
 | 
						|
            glDeleteShader(shader_ids[index]);
 | 
						|
            sb::Log::Multi() << "Deleted shader ID " << shader_ids[index];
 | 
						|
            sb::Log::gl_errors("after deleting shader");
 | 
						|
        }
 | 
						|
 | 
						|
        /* Delete the shader program */
 | 
						|
        glDeleteProgram(program);
 | 
						|
        sb::Log::Multi() << "Deleted shader program " << program;
 | 
						|
        sb::Log::gl_errors("after deleting shader program");
 | 
						|
    }
 | 
						|
 | 
						|
    /* Dynamically select version based on whether the build is for OpenGL or OpenGL ES. */
 | 
						|
    std::string version;
 | 
						|
    if (configuration()("display", "render driver") == "opengl")
 | 
						|
    {
 | 
						|
        version = configuration()("gl", "glsl version");
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        version = configuration()("gl", "glsl version es");
 | 
						|
    }
 | 
						|
 | 
						|
    /* Load shader programs */
 | 
						|
    if (configuration()().contains("shaders") && configuration()("shaders").size() > 0)
 | 
						|
    {
 | 
						|
        for (const auto& [name, program] : configuration()("shaders").items())
 | 
						|
        {
 | 
						|
            if (program.is_object() && program.contains("vertex") && program.contains("fragment"))
 | 
						|
            {
 | 
						|
                /* Generate an ID for the shader */
 | 
						|
                GLuint program_id { glCreateProgram() };
 | 
						|
                
 | 
						|
                /* Attach vertex and fragment shaders. */
 | 
						|
                for (const std::string type : {"vertex", "fragment"})
 | 
						|
                {
 | 
						|
                    int type_id { type == "vertex" ? GL_VERTEX_SHADER : GL_FRAGMENT_SHADER };
 | 
						|
                    GLuint shader_id;
 | 
						|
                    if (program.at(type).is_array())
 | 
						|
                    {
 | 
						|
                        shader_id = sb::shader::load(version, program.at(type).get<std::vector<fs::path>>(), type_id);
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        shader_id = sb::shader::load(version, program.at(type).get<fs::path>(), type_id);
 | 
						|
                    }
 | 
						|
                    glAttachShader(program_id, shader_id);
 | 
						|
                    sb::Log::gl_errors("after attaching " + type + " shader for program " + name);
 | 
						|
                }
 | 
						|
 | 
						|
                /* Link program */
 | 
						|
                sb::shader::link(program_id);
 | 
						|
                sb::Log::gl_errors("after linking shader program " + name);
 | 
						|
 | 
						|
                /* Shader has compiled and linked successfully. Store the shader to be used at any point during the
 | 
						|
                 * runtime of the game. */
 | 
						|
                shader_programs[name] = program_id;
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                std::runtime_error("Malformed shader program definition for program " + name);
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        std::runtime_error("No shaders defined in the configuration.");
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::use_shader_program(const std::string& name)
 | 
						|
{
 | 
						|
    /* Call glUseProgram and assign uniform IDs */
 | 
						|
    sb::shader::use(shader_programs.at(name), uniform);
 | 
						|
 | 
						|
    /* Texture uniform doesn't need to be set per draw because only one texture is used per draw */
 | 
						|
    glUniform1i(uniform.at("model_texture"), 0);
 | 
						|
 | 
						|
    /* Resolution only needs to be set when the window is resized. */
 | 
						|
    glm::vec2 resolution { display.window_box().size() };
 | 
						|
    glUniform2f(uniform.at("resolution"), resolution.x, resolution.y);
 | 
						|
 | 
						|
    /* Initialize color addition to zero. */
 | 
						|
    glUniform4f(uniform.at("color_addition"), 0.0f, 0.0f, 0.0f, 0.0f);
 | 
						|
 | 
						|
    /* Bind UV attributes now because they will not be changing */
 | 
						|
    sb::Plane::uv->bind("vertex_uv", shader_programs.at(name));
 | 
						|
 | 
						|
    /* Print uniform values for debugging */
 | 
						|
    for (const auto& [name, value] : uniform)
 | 
						|
    {
 | 
						|
        sb::Log::Multi(sb::Log::DEBUG) << "Uniform " << name << " set to " << value << sb::Log::end;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Set active program id */
 | 
						|
    shader_program = shader_programs.at(name);
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::use_shader_by_level(int index)
 | 
						|
{
 | 
						|
    /* Get shader name from world index of the level and load the shader. */
 | 
						|
    std::string shader_name = configuration()("shaders", "order", world_index(index));
 | 
						|
    if (shader_program != shader_programs.at(shader_name))
 | 
						|
    {
 | 
						|
        use_shader_program(shader_name);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Set background style uniforms based on the world index and world sub-index. */
 | 
						|
    if (configuration()("shaders", shader_name).contains("style"))
 | 
						|
    {
 | 
						|
        nlohmann::json style = configuration()("shaders", shader_name, "style");
 | 
						|
        if (style.is_array())
 | 
						|
        {
 | 
						|
            style = style.at(world_sub_index(index) % style.size());
 | 
						|
            for (const std::string color : { "bg_color_1", "bg_color_2" })
 | 
						|
            {
 | 
						|
                if (style.contains(color))
 | 
						|
                {
 | 
						|
                    glUniform4fv(uniform.at(color), 1, &style.at(color).get<glm::fvec4>()[0]);
 | 
						|
                }
 | 
						|
            }
 | 
						|
            for (const std::string control : { "bg_control_1", "bg_control_2", "bg_control_3", "bg_control_4" })
 | 
						|
            {
 | 
						|
                if (style.contains(control))
 | 
						|
                {
 | 
						|
                    glUniform1f(uniform.at(control), style.at(control).get<float>());
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::set_up_buttons()
 | 
						|
{
 | 
						|
    /* Load reusable textures */
 | 
						|
    increment_texture = sb::Texture {configuration()("button", "name", "arrow increment texture").get<std::string>()};
 | 
						|
    increment_texture.load();
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    decrement_texture = sb::Texture {configuration()("button", "name", "arrow decrement texture").get<std::string>()};
 | 
						|
    decrement_texture.load();
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
 | 
						|
    /* Set up text buttons */
 | 
						|
    for (const std::string name : {
 | 
						|
            "start", "resume", "reset", "level increment", "level decrement", "profile increment", "profile decrement",
 | 
						|
            "challenge increment", "challenge decrement", "view increment", "view decrement", "fullscreen text", "bgm",
 | 
						|
            "sfx", "exit"
 | 
						|
        })
 | 
						|
    {
 | 
						|
        /* Use glyph-enabled font for text with symbols/icons */
 | 
						|
        std::string font;
 | 
						|
        if (name == "resume" || name == "reset" || name == "fullscreen text" || name == "bgm" || name == "sfx" ||
 | 
						|
            name == "exit")
 | 
						|
        {
 | 
						|
            font = "narrow medium";
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            font = "glyph";
 | 
						|
        }
 | 
						|
        sb::Text text {fonts.at(font)};
 | 
						|
        float scale;
 | 
						|
        glm::vec2 dimensions;
 | 
						|
        bool pressed = button.at(name).pressed();
 | 
						|
        if (name == "start" || name == "resume" || name == "reset")
 | 
						|
        {
 | 
						|
            dimensions = configuration()("button", "text dimensions").get<glm::vec2>();
 | 
						|
            scale = configuration()("button", "text scale");
 | 
						|
        }
 | 
						|
        else if (name == "fullscreen text" || name == "bgm" || name == "sfx" || name == "exit")
 | 
						|
        {
 | 
						|
            dimensions = configuration()("button", "fullscreen text dimensions").get<glm::vec2>();
 | 
						|
            scale = configuration()("button", "fullscreen text scale");
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            dimensions = glm::vec2{configuration()("button", "level decrement dimensions")};
 | 
						|
            if (configuration()("button", "level select scale").is_array())
 | 
						|
            {
 | 
						|
                scale = configuration()("button", "level select scale")[1];
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                scale = configuration()("button", "level select scale");
 | 
						|
            }
 | 
						|
        }
 | 
						|
        text.foreground(configuration()("button", "text foreground").get<glm::vec4>());
 | 
						|
        text.background(configuration()("button", "text background").get<glm::vec4>());
 | 
						|
        std::string text_content;
 | 
						|
        if (name != "bgm" && name != "sfx")
 | 
						|
        {
 | 
						|
            text_content = name + " text";
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            text_content = name + " text " + (pressed ? "on" : "off");
 | 
						|
        }
 | 
						|
        text.content(configuration()("button", text_content).get<std::string>());
 | 
						|
        text.dimensions(dimensions);
 | 
						|
        text.refresh();
 | 
						|
        std::string translation_entry;
 | 
						|
        if (name != "bgm" && name != "sfx" && name != "fullscreen text")
 | 
						|
        {
 | 
						|
            translation_entry = name + " translation";
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* If game is paused and it's not the title screen, pause menu must be active */
 | 
						|
            translation_entry = name + " translation " + (unpaused_timer || level_index == 0 ? "home" : "pause");
 | 
						|
        }
 | 
						|
        button.at(name) = sb::Pad<>{text, configuration()("button", translation_entry), scale, dimensions.y / dimensions.x};
 | 
						|
        button.at(name).state(pressed);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Disable buttons if necessary */
 | 
						|
    if (!configuration()("display", "fullscreen enabled"))
 | 
						|
    {
 | 
						|
        button.at("fullscreen text").enabled(false);
 | 
						|
    }
 | 
						|
    if (!configuration()("display", "exit enabled"))
 | 
						|
    {
 | 
						|
        button.at("exit").enabled(false);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Replace start text texture with arcade prompt text in arcade-only mode */
 | 
						|
    if (configuration()("arcade", "arcade only"))
 | 
						|
    {
 | 
						|
        /* Dynamically build prompt message based on how many credits are still needed to begin */
 | 
						|
        std::string text;
 | 
						|
        if (!configuration()("arcade", "credits enabled") || credits >= configuration()("arcade", "credits required"))
 | 
						|
        {
 | 
						|
            /* Game can be started */
 | 
						|
            text = configuration()("arcade", "start message");
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* Display required credits as an int if it is a round number or as a float otherwise */
 | 
						|
            std::ostringstream difference_formatted;
 | 
						|
            long double difference = configuration()("arcade", "credits required").get<long double>() - credits;
 | 
						|
            if (int(difference) == difference)
 | 
						|
            {
 | 
						|
                difference_formatted << int(difference);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                difference_formatted << difference;
 | 
						|
            }
 | 
						|
 | 
						|
            /* Build dynamic prompt for more credits based on the name used for credits and the amount needed. */
 | 
						|
            text = configuration()("arcade", "add credits message");
 | 
						|
            text.replace(text.find("{amount}"), std::string("{amount}").size(), difference_formatted.str());
 | 
						|
            text.replace(text.find("{name}"), std::string("{name}").size(), configuration()("arcade", "credit name"));
 | 
						|
            if (difference != 1.0f)
 | 
						|
            {
 | 
						|
                text += "S";
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Create a text plane */
 | 
						|
        sb::Text message {
 | 
						|
            fonts.at(configuration()("button", "arcade prompt", "font")), text,
 | 
						|
            configuration()("button", "arcade prompt", "foreground").get<glm::vec4>(),
 | 
						|
            configuration()("button", "arcade prompt", "background").get<glm::vec4>(),
 | 
						|
            configuration()("button", "arcade prompt", "dimensions").get<glm::vec2>()
 | 
						|
        };
 | 
						|
        message.refresh();
 | 
						|
 | 
						|
        /* Add the text plane to a pad object */
 | 
						|
        button.at("start") = sb::Pad<>{
 | 
						|
            message, configuration()("button", "arcade prompt", "translation"),
 | 
						|
            configuration()("button", "arcade prompt", "scale"),
 | 
						|
            configuration()("button", "arcade prompt", "ratio")
 | 
						|
        };
 | 
						|
 | 
						|
        /* Set the text label for displaying the current amount of credits */
 | 
						|
        if (configuration()("arcade", "credits enabled"))
 | 
						|
        {
 | 
						|
            label.at("credits available").foreground(
 | 
						|
                configuration()("arcade", "credits available", "foreground").get<glm::vec4>());
 | 
						|
            label.at("credits available").background(
 | 
						|
                configuration()("arcade", "credits available", "background").get<glm::vec4>());
 | 
						|
            label.at("credits available").untransform();
 | 
						|
 | 
						|
            text = configuration()("arcade", "credits available", "text");
 | 
						|
            text.replace(text.find("{name}"), std::string("{name}").size(),
 | 
						|
                         configuration()("arcade", "credit name").get<std::string>() + "S");
 | 
						|
            std::ostringstream amount;
 | 
						|
            if (int(credits) == credits)
 | 
						|
            {
 | 
						|
                amount << int(credits);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                amount << credits;
 | 
						|
            }
 | 
						|
            text.replace(text.find("{amount}"), std::string("{amount}").size(), amount.str());
 | 
						|
 | 
						|
            label.at("credits available").content(text);
 | 
						|
            label.at("credits available").translate(configuration()("arcade", "credits available", "translation"));
 | 
						|
            float scale { configuration()("arcade", "credits available", "scale") };
 | 
						|
            glm::fvec2 dimensions { label.at("credits available").dimensions() };
 | 
						|
            label.at("credits available").scale(glm::vec3{scale * (dimensions.x / dimensions.y), scale, 1.0f});
 | 
						|
            label.at("credits available").refresh();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Check challenge index */
 | 
						|
    const nlohmann::json::string_t& challenge_name = configuration()("challenge", challenge_index, "name").
 | 
						|
        get_ref<const nlohmann::json::string_t&>();
 | 
						|
 | 
						|
    /* Set up text button callbacks */
 | 
						|
    button.at("start").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        /* Prevent start from running if arcade-only mode is active and there are not enough credits */
 | 
						|
        if (!configuration()("arcade", "arcade only") || !configuration()("arcade", "credits enabled") ||
 | 
						|
            credits >= configuration()("arcade", "credits required"))
 | 
						|
        {
 | 
						|
            if (challenge_name == "NEW QUEST")
 | 
						|
            {
 | 
						|
                /* Check if a quest is in-progress. If so, pop up a confirmation dialog. Do not check in demo mode, or
 | 
						|
                 * if the confirmation dialog is already active (in this case, the only way to press start is assumed to
 | 
						|
                 * be through a forwarded press from the confirmation dialog confirm button).
 | 
						|
                 */
 | 
						|
                if (!confirming_new_quest && !configuration()("demo", "active") &&
 | 
						|
                    (progress<int>("quest level") > 1 || progress<float>("quest checkpoint") > 1.0f))
 | 
						|
                {
 | 
						|
                    confirming_new_quest = true;
 | 
						|
                    selected.reset();
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    confirming_new_quest = false;
 | 
						|
                    configuration()["progress"]["quest level"] = 1;
 | 
						|
                    configuration()["progress"]["quest checkpoint"] = 0.0f;
 | 
						|
                    configuration()["progress"]["quest difficulty"] = profile_index;
 | 
						|
                    configuration()["progress"]["quest time"] = 0.0f;
 | 
						|
                    configuration()["progress"]["quest bank"] = bank_serialized(bank_init());
 | 
						|
                    configuration()["progress"]["quest deaths"] = 0;
 | 
						|
                    challenge_index = 0;
 | 
						|
                    configuration()["progress"]["current challenge"] = challenge_index;
 | 
						|
                }
 | 
						|
            }
 | 
						|
            else if (challenge_name == "ARCADE")
 | 
						|
            {
 | 
						|
                /* Check if an arcade run is in-progress. If so, pop up a confirmation dialog. Do not check in
 | 
						|
                 * arcade-only mode, or if the confirmation dialog is already active (in this case, the only way to
 | 
						|
                 * press start is assumed to be through a forwarded press from the confirmation dialog confirm button).
 | 
						|
                 */
 | 
						|
                if (!confirming_new_arcade && !configuration()("arcade", "arcade only") &&
 | 
						|
                    (progress<int>("arcade level") > 1 || progress<float>("arcade checkpoint") > 1.0f))
 | 
						|
                {
 | 
						|
                    confirming_new_arcade = true;
 | 
						|
                    selected.reset();
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    confirming_new_arcade = false;
 | 
						|
                    configuration()["progress"]["arcade level"] = 1;
 | 
						|
                    configuration()["progress"]["arcade checkpoint"] = 0.0f;
 | 
						|
                    configuration()["progress"]["arcade difficulty"] = profile_index;
 | 
						|
                    configuration()["progress"]["arcade max distance"] = 0;
 | 
						|
                    configuration()["progress"]["arcade time"] = 0.0f;
 | 
						|
                    configuration()["progress"]["arcade bank"] = bank_serialized(bank_init());
 | 
						|
                    configuration()["progress"]["arcade deaths"] = 0;
 | 
						|
                    challenge_index = 3;
 | 
						|
                    configuration()["progress"]["current challenge"] = challenge_index;
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            /* Remove credits if they are enabled */
 | 
						|
            if (configuration()("arcade", "arcade only") && configuration()("arcade", "credits enabled"))
 | 
						|
            {
 | 
						|
                credits -= configuration()("arcade", "credits required").get<float>();
 | 
						|
            }
 | 
						|
 | 
						|
#if defined(__COOLMATH__)
 | 
						|
            /* Coolmath API */
 | 
						|
            EM_ASM(
 | 
						|
                if (parent.cmgGameEvent !== undefined)
 | 
						|
                {
 | 
						|
                    console.log("cmgGameEvent start"); parent.cmgGameEvent("start");
 | 
						|
                });
 | 
						|
#endif
 | 
						|
 | 
						|
            /* Only start the game if the confirmation dialog hasn't been activated */
 | 
						|
            if (!confirming_new_quest && !confirming_new_arcade)
 | 
						|
            {
 | 
						|
                load_level(level_select_index);
 | 
						|
 | 
						|
                /* Track game starts */
 | 
						|
                stat_progress.increment_stat(stats["STAT_GAMES_STARTED"], 1, achievements, stats);
 | 
						|
                if (arcade())
 | 
						|
                {
 | 
						|
                    stat_progress.increment_stat(stats["STAT_ARCADE_RUNS_STARTED"], 1, achievements, stats);
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
    });
 | 
						|
    button.at("resume").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        sb::Delegate::post("resume", false);
 | 
						|
    });
 | 
						|
    button.at("reset").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        sb::Delegate::post(reset_command_name, false);
 | 
						|
    });
 | 
						|
    button.at("fullscreen text").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        sb::Delegate::post("fullscreen");
 | 
						|
    });
 | 
						|
    button.at("bgm").on_state_change([&](bool state){
 | 
						|
 | 
						|
        /* Enable/disable BGM and menu music. */
 | 
						|
        bgm.enabled(state);
 | 
						|
        for (auto& [name, chunk] : audio)
 | 
						|
        {
 | 
						|
            if (name == "menu")
 | 
						|
            {
 | 
						|
                chunk.enabled(state);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Play menu music when button flipped on, or stop when flipped off. The button is always in a menu screen, so
 | 
						|
         * the BGM shouldn't be turned on or off. */
 | 
						|
        if (state)
 | 
						|
        {
 | 
						|
            audio.at("menu").play();
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            audio.at("menu").stop();
 | 
						|
        }
 | 
						|
        set_up_buttons();
 | 
						|
 | 
						|
        /* Store BGM state in the user's preferences and save preferences to disk */
 | 
						|
        preferences.config(!state, "audio", "bgm muted");
 | 
						|
        preferences.merge(configuration());
 | 
						|
        preferences.save(configuration()("storage", "preferences file"));
 | 
						|
    });
 | 
						|
    button.at("sfx").on_state_change([&](bool state){
 | 
						|
        for (auto& [name, chunk] : audio)
 | 
						|
        {
 | 
						|
            if (name != "menu")
 | 
						|
            {
 | 
						|
                chunk.enabled(state);
 | 
						|
            }
 | 
						|
        }
 | 
						|
        set_up_buttons();
 | 
						|
 | 
						|
        /* Store SFX state in the user's preferences and save preferences to disk */
 | 
						|
        preferences.config(!state, "audio", "sfx muted");
 | 
						|
        preferences.merge(configuration());
 | 
						|
        preferences.save(configuration()("storage", "preferences file"));
 | 
						|
    });
 | 
						|
    button.at("exit").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
/* Disable button on WASM builds since they don't currently have a launcher to exit to */
 | 
						|
#if !defined(EMSCRIPTEN)
 | 
						|
        flag_to_end();
 | 
						|
#endif
 | 
						|
    });
 | 
						|
 | 
						|
    /* Set up pause button */
 | 
						|
    bool visible = button.at("pause").visible();
 | 
						|
    sb::Texture pause_texture {configuration()("button", "pause texture").get<std::string>()};
 | 
						|
    pause_texture.load();
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    sb::Plane pause_plane;
 | 
						|
    pause_plane.texture(pause_texture);
 | 
						|
    button.at("pause") = sb::Pad<>{pause_plane, configuration()("button", "pause translation"),
 | 
						|
                                   configuration()("button", "pause scale"), 1.0f};
 | 
						|
    button.at("pause").visible(visible);
 | 
						|
    button.at("pause").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        sb::Delegate::post("pause", false);
 | 
						|
    });
 | 
						|
 | 
						|
    /* Set up volume button */
 | 
						|
    bool original_state = button.at("volume").pressed();
 | 
						|
    visible = button.at("volume").visible();
 | 
						|
    sb::Texture volume_off_texture {configuration()("button", "volume off texture").get<std::string>()};
 | 
						|
    volume_off_texture.load();
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    sb::Texture volume_on_texture {configuration()("button", "volume on texture").get<std::string>()};
 | 
						|
    volume_on_texture.load();
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    sb::Plane volume_plane;
 | 
						|
    volume_plane.texture(volume_off_texture);
 | 
						|
    volume_plane.texture(volume_on_texture);
 | 
						|
    button.at("volume") = sb::Pad<>{
 | 
						|
        volume_plane, configuration()("button", "volume translation"), configuration()("button", "volume scale"), 1.0f};
 | 
						|
    button.at("volume").state(original_state);
 | 
						|
    button.at("volume").visible(visible);
 | 
						|
    button.at("volume").on_state_change([&](bool state){
 | 
						|
        /* Mute or unmute (to full volume) depending on the state of the button */
 | 
						|
        if (Mix_QuerySpec(nullptr, nullptr, nullptr) != 0)
 | 
						|
        {
 | 
						|
            if (state)
 | 
						|
            {
 | 
						|
                Mix_Volume(-1, 128);
 | 
						|
                Mix_VolumeMusic(128);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                Mix_Volume(-1, 0);
 | 
						|
                Mix_VolumeMusic(0);
 | 
						|
            }
 | 
						|
 | 
						|
            /* Store the audio mute state in the user's preferences and save preferences to disk */
 | 
						|
            preferences.config(!state, "audio", "muted");
 | 
						|
            preferences.merge(configuration());
 | 
						|
            preferences.save(configuration()("storage", "preferences file"));
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            sb::Log::log("Cannot mute or unmute. Audio device is not open.", sb::Log::WARN);
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    /* Set up spinners */
 | 
						|
    for (const std::string name : {"profile", "level select", "challenge", "view"})
 | 
						|
    {
 | 
						|
        glm::vec2 dimensions {configuration()("button", name + " dimensions")};
 | 
						|
        label.at(name).foreground(configuration()("button", "text foreground").get<glm::vec4>());
 | 
						|
        label.at(name).background(configuration()("button", "text background").get<glm::vec4>());
 | 
						|
        label.at(name).untransform();
 | 
						|
        std::ostringstream message;
 | 
						|
        message << configuration()("button", name + " text").get<std::string>();
 | 
						|
        if (name == "profile")
 | 
						|
        {
 | 
						|
            message << configuration()("character", "profile", profile_index, "name").get<std::string>();
 | 
						|
        }
 | 
						|
        else if (name == "level select")
 | 
						|
        {
 | 
						|
            message << level_select_index;
 | 
						|
        }
 | 
						|
        else if (name == "challenge")
 | 
						|
        {
 | 
						|
            message << challenge_name;
 | 
						|
        }
 | 
						|
        else if (name == "view")
 | 
						|
        {
 | 
						|
            message << configuration()("view", view_index, "name").get<std::string>();
 | 
						|
        }
 | 
						|
        label.at(name).content(message.str());
 | 
						|
        label.at(name).translate(configuration()("button", name + " translation"));
 | 
						|
        label.at(name).scale(configuration()("button", name + " scale"));
 | 
						|
        label.at(name).dimensions(dimensions);
 | 
						|
        label.at(name).refresh();
 | 
						|
    }
 | 
						|
    button.at("profile decrement").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        /* Disable in arcade-only mode and resume game modes */
 | 
						|
        if (!configuration()("arcade", "arcade only") && challenge_name != "RESUME QUEST" &&
 | 
						|
            challenge_name != "RESUME ARCADE")
 | 
						|
        {
 | 
						|
            if (--profile_index < 0)
 | 
						|
            {
 | 
						|
                profile_index = configuration()("progress", "max difficulty");
 | 
						|
            }
 | 
						|
            configuration()["progress"]["current difficulty"] = profile_index;
 | 
						|
            write_progress();
 | 
						|
            set_up_buttons();
 | 
						|
            character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
        }
 | 
						|
    });
 | 
						|
    button.at("profile increment").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        /* Disable in arcade-only mode and resume game modes */
 | 
						|
        if (!configuration()("arcade", "arcade only") && challenge_name != "RESUME QUEST" &&
 | 
						|
            challenge_name != "RESUME ARCADE")
 | 
						|
        {
 | 
						|
            if (++profile_index > configuration()("progress", "max difficulty"))
 | 
						|
            {
 | 
						|
                profile_index = 0;
 | 
						|
            }
 | 
						|
            if (profile_index == configuration()("progress", "max difficulty"))
 | 
						|
            {
 | 
						|
                /* If the max difficulty was selected, clamp the level to the max level. */
 | 
						|
                if (level_select_index > configuration()("progress", "max level"))
 | 
						|
                {
 | 
						|
                    level_select_index = configuration()("progress", "max level");
 | 
						|
                }
 | 
						|
            }
 | 
						|
            configuration()["progress"]["current difficulty"] = profile_index;
 | 
						|
            write_progress();
 | 
						|
            set_up_buttons();
 | 
						|
            character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
        }
 | 
						|
    });
 | 
						|
    button.at("level decrement").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        /* Only allow level select in level select mode */
 | 
						|
        if (challenge_name == "LEVEL SELECT")
 | 
						|
        {
 | 
						|
            /* If the level is decreased below 1, wrap to the last level if the current difficulty is complete,
 | 
						|
             * otherwise wrap to the max level unlocked. */
 | 
						|
            if (--level_select_index < 1)
 | 
						|
            {
 | 
						|
                if (profile_index < configuration()("progress", "max difficulty"))
 | 
						|
                {
 | 
						|
                    level_select_index = configuration()("levels").size() - 2;
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    level_select_index = configuration()("progress", "max level");
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Save state and redraw button */
 | 
						|
        configuration()["progress"]["current level"] = level_select_index;
 | 
						|
        write_progress();
 | 
						|
        set_up_buttons();
 | 
						|
    });
 | 
						|
    button.at("level increment").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        /* Only allow level select in level select mode */
 | 
						|
        if (challenge_name == "LEVEL SELECT")
 | 
						|
        {
 | 
						|
            /* If the level is increased past the total number of levels or past the max level unlocked on the current
 | 
						|
             * difficulty, wrap the spinner back to 1. */
 | 
						|
            if (++level_select_index >= static_cast<int>(configuration()("levels").size() - 1) || (
 | 
						|
                    profile_index == configuration()("progress", "max difficulty") &&
 | 
						|
                    level_select_index > configuration()("progress", "max level")))
 | 
						|
            {
 | 
						|
                level_select_index = 1;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Save state and redraw button */
 | 
						|
        configuration()["progress"]["current level"] = level_select_index;
 | 
						|
        write_progress();
 | 
						|
        set_up_buttons();
 | 
						|
    });
 | 
						|
    button.at("challenge decrement").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        /* Only allow change when not in arcade-only or demo mode */
 | 
						|
        if (!configuration()("arcade", "arcade only") && !configuration()("demo", "active"))
 | 
						|
        {
 | 
						|
            if (--challenge_index < 0) challenge_index = max_challenge();
 | 
						|
            if (skip_resume_quest() || skip_resume_arcade() || skip_level_select())
 | 
						|
            {
 | 
						|
                button.at("challenge decrement").press();
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                toggle_challenge();
 | 
						|
            }
 | 
						|
        }
 | 
						|
    });
 | 
						|
    button.at("challenge increment").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        /* Only allow change when not in arcade-only or demo mode */
 | 
						|
        if (!configuration()("arcade", "arcade only") && !configuration()("demo", "active"))
 | 
						|
        {
 | 
						|
            if (++challenge_index > max_challenge()) challenge_index = 0;
 | 
						|
            if (skip_resume_quest() || skip_resume_arcade() || skip_level_select())
 | 
						|
            {
 | 
						|
                button.at("challenge increment").press();
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                toggle_challenge();
 | 
						|
            }
 | 
						|
        }
 | 
						|
    });
 | 
						|
    button.at("view decrement").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        if (--view_index < 0)
 | 
						|
        {
 | 
						|
            view_index = configuration()("progress", "max view");
 | 
						|
        }
 | 
						|
        configuration()["progress"]["current view"] = view_index;
 | 
						|
        write_progress();
 | 
						|
        set_up_buttons();
 | 
						|
    });
 | 
						|
    button.at("view increment").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        if (++view_index > configuration()("progress", "max view"))
 | 
						|
        {
 | 
						|
            view_index = 0;
 | 
						|
        }
 | 
						|
        configuration()["progress"]["current view"] = view_index;
 | 
						|
        write_progress();
 | 
						|
        set_up_buttons();
 | 
						|
    });
 | 
						|
 | 
						|
    /* Set up play button */
 | 
						|
    original_state = button.at("play").pressed();
 | 
						|
    sb::Texture play_texture {configuration()("button", "play texture").get<std::string>()};
 | 
						|
    play_texture.load();
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    sb::Plane play_plane;
 | 
						|
    play_plane.texture(play_texture);
 | 
						|
    button.at("play") = sb::Pad<>{
 | 
						|
        play_plane, configuration()("button", "play translation"), configuration()("button", "play scale"),
 | 
						|
        configuration()("button", "play scale ratio")};
 | 
						|
    button.at("play").state(original_state);
 | 
						|
    button.at("play").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        if (splash.size() > 0)
 | 
						|
        {
 | 
						|
            splash_index = 0;
 | 
						|
            splash_animation.play_once(splash[0].length);
 | 
						|
            world_color = splash[0].background.normal();
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            load_level(0);
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    /* Set up name entry buttons */
 | 
						|
    for (const std::string character_index : {"1", "2", "3"})
 | 
						|
    {
 | 
						|
        glm::vec2 character_dimensions {configuration()("button", "name", "character dimensions")};
 | 
						|
        sb::Text character {fonts.at("large"), "", configuration()("display", "clock hud foreground").get<glm::vec4>(),
 | 
						|
                            configuration()("display", "clock hud background").get<glm::vec4>(), character_dimensions};
 | 
						|
        character.content(name_entry[std::stoi(character_index) - 1]);
 | 
						|
        character.refresh();
 | 
						|
        button.at("name " + character_index) = sb::Pad<>{
 | 
						|
            character,
 | 
						|
            {configuration()("button", "name", "character " + character_index + " x"),
 | 
						|
             configuration()("button", "name", "character y")},
 | 
						|
            configuration()("button", "name", "character scale")[1], character_dimensions.y / character_dimensions.x};
 | 
						|
        sb::Plane increment_plane;
 | 
						|
        increment_plane.texture(increment_texture);
 | 
						|
        glm::vec2 arrow_dimensions {configuration()("button", "name", "arrow dimensions")};
 | 
						|
        button.at("name " + character_index + " increment") = sb::Pad<>{
 | 
						|
            increment_plane,
 | 
						|
            {
 | 
						|
                configuration()("button", "name", "character " + character_index + " x"),
 | 
						|
                configuration()("button", "name", "arrow increment y")
 | 
						|
            },
 | 
						|
            configuration()("button", "name", "arrow scale")[1], arrow_dimensions.y / arrow_dimensions.x};
 | 
						|
        sb::Plane decrement_plane;
 | 
						|
        decrement_plane.texture(decrement_texture);
 | 
						|
        button.at("name " + character_index + " decrement") = sb::Pad<>{
 | 
						|
            decrement_plane,
 | 
						|
            {
 | 
						|
                configuration()("button", "name", "character " + character_index + " x"),
 | 
						|
                configuration()("button", "name", "arrow decrement y")
 | 
						|
            },
 | 
						|
            configuration()("button", "name", "arrow scale")[1], arrow_dimensions.y / arrow_dimensions.x};
 | 
						|
        button.at("name " + character_index).on_state_change([&, character_index]([[maybe_unused]] bool state){
 | 
						|
            name_entry_index = std::stoi(character_index) - 1;
 | 
						|
        });
 | 
						|
        button.at("name " + character_index + " increment").on_state_change([&, character_index]([[maybe_unused]] bool state){
 | 
						|
            char current = name_entry[std::stoi(character_index) - 1];
 | 
						|
            if (++current > 'Z')
 | 
						|
            {
 | 
						|
                current = 'A';
 | 
						|
            }
 | 
						|
            name_entry[std::stoi(character_index) - 1] = current;
 | 
						|
            set_up_buttons();
 | 
						|
        });
 | 
						|
        button.at("name " + character_index + " decrement").on_state_change([&, character_index]([[maybe_unused]] bool state){
 | 
						|
            char current = name_entry[std::stoi(character_index) - 1];
 | 
						|
            if (--current < 'A')
 | 
						|
            {
 | 
						|
                current = 'Z';
 | 
						|
            }
 | 
						|
            name_entry[std::stoi(character_index) - 1] = current;
 | 
						|
            set_up_buttons();
 | 
						|
        });
 | 
						|
        if (!configuration()("display", "name entry enabled"))
 | 
						|
        {
 | 
						|
            button.at("name " + character_index + " increment").enabled(false);
 | 
						|
            button.at("name " + character_index + " increment").visible(false);
 | 
						|
            button.at("name " + character_index + " decrement").enabled(false);
 | 
						|
            button.at("name " + character_index + " decrement").visible(false);
 | 
						|
            button.at("name " + character_index).enabled(false);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Set up fullscreen button */
 | 
						|
    visible = button.at("fullscreen").visible();
 | 
						|
    sb::Texture fullscreen_texture {configuration()("button", "fullscreen texture").get<std::string>()};
 | 
						|
    fullscreen_texture.load();
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    sb::Plane fullscreen_plane;
 | 
						|
    fullscreen_plane.texture(fullscreen_texture);
 | 
						|
    button.at("fullscreen") = sb::Pad<>{
 | 
						|
        fullscreen_plane, configuration()("button", "fullscreen translation"), configuration()("button", "fullscreen scale"),
 | 
						|
        configuration()("button", "fullscreen scale ratio")};
 | 
						|
    button.at("fullscreen").visible(visible);
 | 
						|
    button.at("fullscreen").on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        sb::Delegate::post("fullscreen");
 | 
						|
    });
 | 
						|
 | 
						|
    /* Set up steam button */
 | 
						|
    sb::Texture steam_texture {configuration()("texture", "steam button").get<std::string>()};
 | 
						|
    steam_texture.filter(GL_LINEAR);
 | 
						|
    steam_texture.load();
 | 
						|
    sb::Plane steam_plane;
 | 
						|
    steam_plane.texture(steam_texture);
 | 
						|
    glm::vec3 steam_translation = configuration()("button", "steam translation");
 | 
						|
    button.at("steam") = sb::Pad<>{
 | 
						|
        steam_plane, steam_translation, configuration()("button", "steam scale"),
 | 
						|
        configuration()("button", "steam ratio")};
 | 
						|
 | 
						|
    /* Set up dank logo button */
 | 
						|
    sb::Texture dank_texture {configuration()("texture", "dank logo").get<std::string>()};
 | 
						|
    dank_texture.filter(GL_LINEAR);
 | 
						|
    dank_texture.load();
 | 
						|
    sb::Plane dank_plane;
 | 
						|
    dank_plane.texture(dank_texture);
 | 
						|
    glm::vec3 dank_translation = configuration()("button", "dank logo translation");
 | 
						|
    button.at("dank") = sb::Pad<>{
 | 
						|
        dank_plane, dank_translation, configuration()("button", "dank logo scale"),
 | 
						|
        configuration()("button", "dank logo ratio")};
 | 
						|
 | 
						|
    /* Set up replay restart button */
 | 
						|
    nlohmann::json style = configuration()("replay", "restart button");
 | 
						|
    glm::ivec2 dimensions { style.at("dimensions") };
 | 
						|
    sb::Text message {
 | 
						|
        fonts.at(style.at("font")),
 | 
						|
        style.at("text"),
 | 
						|
        style.at("foreground").get<glm::vec4>(),
 | 
						|
        style.at("background").get<glm::vec4>(),
 | 
						|
        dimensions
 | 
						|
    };
 | 
						|
    message.refresh();
 | 
						|
    float aspect_ratio { float(dimensions.y) / dimensions.x };
 | 
						|
    button.at("replay restart") = sb::Pad<> { message, style.at("translation"), style.at("scale"), aspect_ratio };
 | 
						|
 | 
						|
    /* Set up replay exit button */
 | 
						|
    style = configuration()("replay", "exit button");
 | 
						|
    dimensions = style.at("dimensions");
 | 
						|
    message = sb::Text {
 | 
						|
        fonts.at(style.at("font")),
 | 
						|
        style.at("text"),
 | 
						|
        style.at("foreground").get<glm::vec4>(),
 | 
						|
        style.at("background").get<glm::vec4>(),
 | 
						|
        dimensions
 | 
						|
    };
 | 
						|
    message.refresh();
 | 
						|
    aspect_ratio = float(dimensions.y) / dimensions.x;
 | 
						|
    button.at("replay exit") = sb::Pad<> { message, style.at("translation"), style.at("scale"), aspect_ratio };
 | 
						|
 | 
						|
    /* Add default audio to all buttons */
 | 
						|
    std::function<void(sb::Pad<>&)> apply_audio = [&](sb::Pad<>& pad){
 | 
						|
        pad.select_audio(audio.at(configuration()("button", "default select audio")));
 | 
						|
        pad.click_audio(audio.at(configuration()("button", "default click audio")));
 | 
						|
    };
 | 
						|
    for (auto& [name, pad] : button) apply_audio(pad);
 | 
						|
    for (sb::Pad<>& pad : replay_buttons) apply_audio(pad);
 | 
						|
    for (auto & [name, pad] : operator_menu_buttons) apply_audio(pad);
 | 
						|
    apply_audio(confirmation_confirm_button);
 | 
						|
    apply_audio(confirmation_cancel_button);
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::toggle_challenge()
 | 
						|
{
 | 
						|
    const nlohmann::json::string_t& challenge_name = configuration()("challenge", challenge_index, "name").
 | 
						|
        get_ref<const nlohmann::json::string_t&>();
 | 
						|
    bool play_mode_menu_active = challenge_name != "OPTIONS" && challenge_name != "ACHIEVEMENTS" &&
 | 
						|
        challenge_name != "STATS" && challenge_name != "REPLAYS";
 | 
						|
 | 
						|
    /* In resume modes, set the level select and difficulty to the saved values. */
 | 
						|
    if (challenge_name == "RESUME QUEST" || !play_mode_menu_active)
 | 
						|
    {
 | 
						|
        level_select_index = configuration()("progress", "quest level").get<int>();
 | 
						|
        profile_index = configuration()("progress", "quest difficulty").get<int>();
 | 
						|
        configuration()["progress"]["current difficulty"] = profile_index;
 | 
						|
        character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
    }
 | 
						|
    else if (challenge_name == "RESUME ARCADE")
 | 
						|
    {
 | 
						|
        level_select_index = configuration()("progress", "arcade level").get<int>();
 | 
						|
        profile_index = configuration()("progress", "arcade difficulty").get<int>();
 | 
						|
        configuration()["progress"]["current difficulty"] = profile_index;
 | 
						|
        character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
    }
 | 
						|
 | 
						|
    /* In new game modes, set the level select to 1 and leave the difficulty unchanged. */
 | 
						|
    else if (challenge_name == "ARCADE" || challenge_name == "NEW QUEST")
 | 
						|
    {
 | 
						|
        level_select_index = 1;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Reload achievement and stats graphics to update values displayed */
 | 
						|
    if (challenge_name == "ACHIEVEMENTS")
 | 
						|
    {
 | 
						|
        load_achievements_menu();
 | 
						|
    }
 | 
						|
    else if (challenge_name == "STATS")
 | 
						|
    {
 | 
						|
        load_stats_menu();
 | 
						|
    }
 | 
						|
    else if (challenge_name == "REPLAYS")
 | 
						|
    {
 | 
						|
        load_replays_menu();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Save menu selection if a play mode menu is active */
 | 
						|
    if (play_mode_menu_active)
 | 
						|
    {
 | 
						|
        configuration()["progress"]["current challenge"] = challenge_index;
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        /* Save challenge selection as quest instead of non-play mode */
 | 
						|
        if (configuration()("progress", "quest level") == 1 && configuration()("progress", "quest checkpoint") == 0.0f)
 | 
						|
        {
 | 
						|
            /* Resume */
 | 
						|
            configuration()["progress"]["current challenge"] = 1;
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* New */
 | 
						|
            configuration()["progress"]["current challenge"] = 0;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    configuration()["progress"]["current level"] = level_select_index;
 | 
						|
    write_progress();
 | 
						|
    set_up_buttons();
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::set_up_diagnostic_display()
 | 
						|
{
 | 
						|
    /* Style the FPS indicator */
 | 
						|
    float modifier = configuration()("diagnostic", "fps scale").get<float>();
 | 
						|
    glm::vec3 scale = {modifier, modifier * window_box().aspect(), 1.0f};
 | 
						|
    label.at("fps").foreground(configuration()("diagnostic", "fps foreground").get<glm::vec4>());
 | 
						|
    label.at("fps").background(configuration()("diagnostic", "fps background").get<glm::vec4>());
 | 
						|
    label.at("fps").untransform();
 | 
						|
    label.at("fps").translate({1.0f - scale.x, 1.0f - scale.y, 0.0f});
 | 
						|
    label.at("fps").scale(scale);
 | 
						|
 | 
						|
    /* Style the version string indicator */
 | 
						|
    std::string content { configuration()("diagnostic", "version text") };
 | 
						|
    content.replace(content.find("{version}"), std::string("{version}").size(), cakefoot::version);
 | 
						|
    label.at("version").content(content);
 | 
						|
    label.at("version").refresh();
 | 
						|
 | 
						|
    float height {configuration()("diagnostic", "version scale")};
 | 
						|
    glm::fvec2 dimensions {label.at("version").dimensions()};
 | 
						|
    float width {height * (dimensions.x / dimensions.y)};
 | 
						|
 | 
						|
    modifier = configuration()("diagnostic", "version scale");
 | 
						|
    scale = {width, height, 1.0f};
 | 
						|
 | 
						|
    label.at("version").foreground(configuration()("diagnostic", "version foreground").get<glm::vec4>());
 | 
						|
    label.at("version").background(configuration()("diagnostic", "version background").get<glm::vec4>());
 | 
						|
    label.at("version").untransform();
 | 
						|
    label.at("version").translate(configuration()("diagnostic", "version translation"));
 | 
						|
    label.at("version").scale(scale);
 | 
						|
    label.at("version").dimensions(configuration()("diagnostic", "version dimensions"));
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::set_up_hud()
 | 
						|
{
 | 
						|
    set_up_diagnostic_display();
 | 
						|
 | 
						|
    glm::vec3 clock_scale, clock_translation;
 | 
						|
    if (static_cast<std::size_t>(level_index) == _configuration("levels").size() - 1)
 | 
						|
    {
 | 
						|
        label.at("clock").font(fonts.at("large"));
 | 
						|
        if (arcade())
 | 
						|
        {
 | 
						|
            /* Arcade results size */
 | 
						|
            clock_scale = configuration()("display", "arcade time remaining scale");
 | 
						|
            clock_translation = configuration()("display", "arcade time remaining translation");
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* Quest results size */
 | 
						|
            clock_scale = configuration()("display", "clock hud large scale");
 | 
						|
            clock_translation = configuration()("display", "clock hud large translation");
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        label.at("clock").font(font());
 | 
						|
        /* Standard HUD size */
 | 
						|
        clock_scale = configuration()("display", "clock hud scale");
 | 
						|
        clock_translation = configuration()("display", "clock hud translation");
 | 
						|
    }
 | 
						|
 | 
						|
    /* Style the clock */
 | 
						|
    label.at("clock").foreground(configuration()("display", "clock hud foreground").get<glm::vec4>());
 | 
						|
    label.at("clock").background(configuration()("display", "clock hud background").get<glm::vec4>());
 | 
						|
    label.at("clock").untransform();
 | 
						|
    label.at("clock").translate(clock_translation);
 | 
						|
    label.at("clock").scale(clock_scale);
 | 
						|
 | 
						|
    /* Style the level indicator */
 | 
						|
    label.at("level").foreground(configuration()("display", "level hud foreground").get<glm::vec4>());
 | 
						|
    label.at("level").background(configuration()("display", "level hud background").get<glm::vec4>());
 | 
						|
    label.at("level").untransform();
 | 
						|
    label.at("level").translate(configuration()("display", "level hud translation"));
 | 
						|
    label.at("level").scale(configuration()("display", "level hud scale"));
 | 
						|
 | 
						|
    /* Style the game over text */
 | 
						|
    label.at("game over").content(configuration()("display", "game over text"));
 | 
						|
    label.at("game over").foreground(configuration()("display", "game over foreground").get<glm::vec4>());
 | 
						|
    label.at("game over").background(configuration()("display", "game over background").get<glm::vec4>());
 | 
						|
    label.at("game over").untransform();
 | 
						|
    label.at("game over").translate(configuration()("display", "game over translation"));
 | 
						|
    label.at("game over").scale(configuration()("display", "game over scale"));
 | 
						|
    label.at("game over").refresh();
 | 
						|
 | 
						|
    /* Style arcade results */
 | 
						|
    for (const std::string name : {"arcade rank", "arcade distance"})
 | 
						|
    {
 | 
						|
        label.at(name).foreground(configuration()("display", "clock hud foreground").get<glm::vec4>());
 | 
						|
        label.at(name).background(configuration()("display", "clock hud background").get<glm::vec4>());
 | 
						|
        label.at(name).untransform();
 | 
						|
        label.at(name).translate(configuration()("display", name + " translation"));
 | 
						|
        label.at(name).scale(configuration()("display", name + " scale"));
 | 
						|
        label.at(name).dimensions(configuration()("display", name + " dimensions"));
 | 
						|
        label.at(name).refresh();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Style the scoreboard */
 | 
						|
    scoreboard.wrap(configuration()("display", "scoreboard wrap"));
 | 
						|
    scoreboard.foreground(configuration()("display", "scoreboard foreground").get<glm::vec4>());
 | 
						|
    scoreboard.background(configuration()("display", "scoreboard background").get<glm::vec4>());
 | 
						|
    scoreboard.refresh();
 | 
						|
    scoreboard.untransform();
 | 
						|
    scoreboard.translate(configuration()("display", "scoreboard translation"));
 | 
						|
    scoreboard.scale(configuration()("display", "scoreboard scale"));
 | 
						|
 | 
						|
    /* Style the QR code */
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    qr_code.texture(qr_texture);
 | 
						|
    qr_code.translate(configuration()("display", "qr translation").get<glm::vec2>());
 | 
						|
    qr_code.scale(configuration()("display", "qr scale"));
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    qr_code_bg.texture(qr_bg_texture);
 | 
						|
    qr_code_bg.translate(configuration()("display", "qr translation").get<glm::vec2>());
 | 
						|
    qr_code_bg.scale(configuration()("display", "qr scale"));
 | 
						|
 | 
						|
    /* Set up auto save icon */
 | 
						|
    auto_save = sb::Sprite {
 | 
						|
        auto_save_texture,
 | 
						|
        configuration()("display", "auto save scale").get<glm::vec2>()
 | 
						|
    };
 | 
						|
    auto_save.translate(configuration()("display", "auto save translation").get<glm::vec2>());
 | 
						|
 | 
						|
    /* Style the quest best time indicator */
 | 
						|
    label.at("quest best").foreground(configuration()("display", "quest best foreground").get<glm::vec4>());
 | 
						|
    label.at("quest best").background(configuration()("display", "quest best background").get<glm::vec4>());
 | 
						|
    label.at("quest best").untransform();
 | 
						|
    label.at("quest best").translate(configuration()("display", "quest best translation"));
 | 
						|
    label.at("quest best").scale(configuration()("display", "quest best scale"));
 | 
						|
    label.at("quest best").dimensions(configuration()("display", "quest best dimensions"));
 | 
						|
    if (configuration()("progress", "quest best") > 0.0f)
 | 
						|
    {
 | 
						|
        label.at("quest best").content(configuration()("display", "quest best text").get<std::string>() +
 | 
						|
                                       format_clock(configuration()("progress", "quest best")));
 | 
						|
        label.at("quest best").refresh();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Style the playtester thanks text */
 | 
						|
    thanks.wrap(configuration()("ending", "thanks wrap"));
 | 
						|
    thanks.content(configuration()("ending", "thanks"));
 | 
						|
    thanks.foreground(configuration()("ending", "messages foreground").get<glm::vec4>());
 | 
						|
    thanks.background(configuration()("ending", "thanks background").get<glm::vec4>());
 | 
						|
    thanks.untransform();
 | 
						|
    thanks.translate(configuration()("ending", "thanks translation"));
 | 
						|
    thanks.scale(configuration()("ending", "thanks scale"));
 | 
						|
    thanks.refresh();
 | 
						|
 | 
						|
    /* Style the idle warning */
 | 
						|
    label.at("idle warning").content(configuration()("demo", "countdown message"));
 | 
						|
    label.at("idle warning").foreground(configuration()("display", "idle warning foreground").get<glm::vec4>());
 | 
						|
    label.at("idle warning").background(configuration()("display", "idle warning background").get<glm::vec4>());
 | 
						|
    label.at("idle warning").untransform();
 | 
						|
    label.at("idle warning").translate(configuration()("display", "idle warning translation"));
 | 
						|
    label.at("idle warning").scale(configuration()("display", "idle warning scale"));
 | 
						|
    label.at("idle warning").refresh();
 | 
						|
 | 
						|
    /* Style the demo message */
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 | 
						|
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 | 
						|
    demo_message.texture(demo_message_texture);
 | 
						|
    demo_message.translate(configuration()("demo", "message translation").get<glm::vec2>());
 | 
						|
    demo_message.scale(configuration()("demo", "message scale"));
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_vbo()
 | 
						|
{
 | 
						|
    /* Generate ID for the vertex buffer object that will hold all vertex data. Using one buffer for all attributes,
 | 
						|
     * data will be copied in one after the other. */
 | 
						|
    vbo.generate();
 | 
						|
    vbo.bind();
 | 
						|
    sb::Log::gl_errors("after generating and binding VBO");
 | 
						|
 | 
						|
    /*!
 | 
						|
     * Fill VBO with attribute data:
 | 
						|
     *
 | 
						|
     * Postion, UV, and color vertices for a single sb::Plane, and curve.
 | 
						|
     */
 | 
						|
    vbo.allocate(sb::Plane().size() + playing_field.attributes("color")->size() + curve_byte_count, GL_STATIC_DRAW);
 | 
						|
    vbo.add(*sb::Plane::position);
 | 
						|
    vbo.add(*sb::Plane::uv);
 | 
						|
    vbo.add(*sb::Plane::color);
 | 
						|
    for (Curve& curve : curves)
 | 
						|
    {
 | 
						|
        for (sb::Attributes& attr : curve.position)
 | 
						|
        {
 | 
						|
            vbo.add(attr);
 | 
						|
        }
 | 
						|
        vbo.add(curve.color);
 | 
						|
    }
 | 
						|
    sb::Log::gl_errors("after filling VBO");
 | 
						|
 | 
						|
    /* Bind UV attributes now because they will not be changing */
 | 
						|
    sb::Plane::uv->bind("vertex_uv", shader_program);
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::populate_bank_ui()
 | 
						|
{
 | 
						|
    bank_ui.clear();
 | 
						|
    for (int ii = 0; static_cast<std::size_t>(ii) < bank_init().size(); ii++)
 | 
						|
    {
 | 
						|
        bool collected;
 | 
						|
        if (configuration()("challenge", challenge_index, "name") != "LEVEL SELECT")
 | 
						|
        {
 | 
						|
            collected = bank()[ii];
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            collected = ii + 1 == level_index && coin_collected;
 | 
						|
        }
 | 
						|
        const nlohmann::json& coin_ui = configuration()("coin ui");
 | 
						|
        bank_ui.emplace_back(collected ? coin_collected_texture : coin_missing_texture, coin_ui.at("scale"));
 | 
						|
        bank_ui.back().translate(
 | 
						|
            coin_ui.at("translation").get<glm::vec3>() +
 | 
						|
            glm::vec3{
 | 
						|
                ii * coin_ui.at("spacing").get<float>(),
 | 
						|
                ii + 1 == level_index ? coin_ui.at("nub").get<float>() : 0.0f,
 | 
						|
                0.0f
 | 
						|
            });
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
const Curve& Cakefoot::curve() const
 | 
						|
{
 | 
						|
    return curves[curve_index % curves.size()];
 | 
						|
}
 | 
						|
 | 
						|
Curve& Cakefoot::curve()
 | 
						|
{
 | 
						|
    return curves[curve_index % curves.size()];
 | 
						|
}
 | 
						|
 | 
						|
int Cakefoot::world_index(int level_index) const
 | 
						|
{
 | 
						|
    nlohmann::json world = configuration()("world");
 | 
						|
 | 
						|
    if (static_cast<std::size_t>(level_index) != configuration()("levels").size() - 1)
 | 
						|
    {
 | 
						|
        for (std::size_t ii = 0; ii < world.size(); ii++)
 | 
						|
        {
 | 
						|
            if (ii == world.size() - 1 || world[ii + 1].at("start").get<int>() > level_index)
 | 
						|
            {
 | 
						|
                return ii;
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    return 0;
 | 
						|
}
 | 
						|
 | 
						|
int Cakefoot::world_sub_index(int level_index) const
 | 
						|
{
 | 
						|
    nlohmann::json world = configuration()("world");
 | 
						|
 | 
						|
    if (static_cast<std::size_t>(level_index) != configuration()("levels").size() - 1)
 | 
						|
    {
 | 
						|
        for (std::size_t ii = 0; ii < world.size(); ii++)
 | 
						|
        {
 | 
						|
            if (ii == world.size() - 1 || world[ii + 1].at("start").get<int>() > level_index)
 | 
						|
            {
 | 
						|
                return level_index - world[ii].at("start").get<int>();
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    return 0;
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_level(int index, bool is_replay)
 | 
						|
{
 | 
						|
    /* Save run time for unlocking achievements later in the function. */
 | 
						|
    float run_timer_save = run_timer.elapsed();
 | 
						|
 | 
						|
    /* If any replays are playing or recording, cancel them. */
 | 
						|
    replay_playback.reset();
 | 
						|
    replay_recording.reset();
 | 
						|
 | 
						|
    /* Handle run timer and replay at the beginning of loading level. Otherwise, if loading takes a long time, lag
 | 
						|
     * between enemy movement and run timer will occur.*/
 | 
						|
    if (index == 0 || static_cast<std::size_t>(index) == configuration()("levels").size() - 1)
 | 
						|
    {
 | 
						|
        run_timer.off();
 | 
						|
        arcade_limit_warning = false;
 | 
						|
 | 
						|
        /* In arcade mode, reset the clock on the title screen */
 | 
						|
        if (arcade() && index == 0)
 | 
						|
        {
 | 
						|
            run_timer.reset();
 | 
						|
        }
 | 
						|
 | 
						|
        /* Attract mode is canceled when the title screen loads */
 | 
						|
        attract_mode_active = false;
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        /* If it's the first level or a replay, start the run timer from the beginning. */
 | 
						|
        if (index == 1 || is_replay)
 | 
						|
        {
 | 
						|
            run_timer.reset();
 | 
						|
        }
 | 
						|
        run_timer.on();
 | 
						|
 | 
						|
        /* Start recording a new replay if the level is high enough and the player is not resuming at a checkpoint. If
 | 
						|
         * the player is resuming at a checkpoint, a replay would not be valid because replay data for earlier sections
 | 
						|
         * of the level is not available. */
 | 
						|
        if (!is_replay)
 | 
						|
        {
 | 
						|
            bool is_resuming_at_checkpoint {
 | 
						|
                (quest() &&
 | 
						|
                 configuration()("progress", "quest level") == index &&
 | 
						|
                 configuration()("progress", "quest checkpoint").get<float>() > 0) ||
 | 
						|
                (arcade() &&
 | 
						|
                 configuration()("progress", "arcade level") == index &&
 | 
						|
                 configuration()("progress", "arcade checkpoint").get<float>() > 0)
 | 
						|
            };
 | 
						|
            bool is_replay_compatible_level { index >= configuration()("replay", "min level") };
 | 
						|
            if (is_replay_compatible_level && !is_resuming_at_checkpoint)
 | 
						|
            {
 | 
						|
                replay_recording = cakefoot::Replay();
 | 
						|
                sb::Log::Line() << "Start recording replay";
 | 
						|
                replay_record_animation.frame_length(configuration()("replay", "frame length"));
 | 
						|
                replay_record_animation.reset();
 | 
						|
                replay_record_animation.play();
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                if (!is_replay_compatible_level)
 | 
						|
                {
 | 
						|
                    sb::Log::Line() << "Skip recording replay because level is too low";
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    sb::Log::Line() << "Skip recording replay because player is resuming at a checkpoint";
 | 
						|
                } } } }
 | 
						|
 | 
						|
    /* Reset and start the survival and stray timers */
 | 
						|
    if (index != 0 && !end_screen() && !is_replay)
 | 
						|
    {
 | 
						|
        survival_timer.reset();
 | 
						|
        survival_timer.on();
 | 
						|
        stray_timer.reset();
 | 
						|
        stray_timer.on();
 | 
						|
    }
 | 
						|
 | 
						|
    /* In case the player would be resuming a game with no progress, remove the resume option */
 | 
						|
    bool resuming_quest_without_progress = progress<int>("current challenge") == 0 &&
 | 
						|
        progress<int>("quest level") == 1 &&
 | 
						|
        progress<int>("quest checkpoint") == 0.0f;
 | 
						|
    bool resuming_arcade_without_progress = progress<int>("current challenge") == 3 &&
 | 
						|
        progress<int>("arcade level") == 1 &&
 | 
						|
        progress<int>("arcade checkpoint") == 0.0f;
 | 
						|
    if (index == 0 && (resuming_quest_without_progress || resuming_arcade_without_progress))
 | 
						|
    {
 | 
						|
        challenge_index++;
 | 
						|
        toggle_challenge();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Default gamepad selection resets every time a level loads */
 | 
						|
    if (index == 0)
 | 
						|
    {
 | 
						|
        std::string challenge { configuration()("challenge", challenge_index, "name") };
 | 
						|
        if (challenge == "REPLAYS" || challenge == "ACHIEVEMENTS" || challenge == "STATS")
 | 
						|
        {
 | 
						|
            selected.reset();
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            selected = button.at("start");
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        selected = button.at("resume");
 | 
						|
    }
 | 
						|
 | 
						|
    /* Wrap the index if it is out of range. */
 | 
						|
    index = glm::mod(index, static_cast<int>(configuration()("levels").size()));
 | 
						|
 | 
						|
    /* Stop any long playing sounds. Play menu theme on title screen and end screen. Play main theme on any other
 | 
						|
     * level. */
 | 
						|
    audio.at("buildup").stop(1.0f);
 | 
						|
    if (index == 0 || static_cast<std::size_t>(index) == configuration()("levels").size() - 1)
 | 
						|
    {
 | 
						|
        /* If menu theme is already playing, let it continue to play. */
 | 
						|
        if (!audio.at("menu").playing() || audio.at("menu").paused() || audio.at("menu").fading())
 | 
						|
        {
 | 
						|
            audio.at("menu").play();
 | 
						|
        }
 | 
						|
 | 
						|
        bgm.stop();
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        if (bgm.paused() || !bgm.playing())
 | 
						|
        {
 | 
						|
            bgm.play();
 | 
						|
        }
 | 
						|
 | 
						|
        audio.at("menu").stop();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Update indices and reset character. */
 | 
						|
    level_index = index;
 | 
						|
    curve_index = index;
 | 
						|
    std::string challenge_name { configuration()("challenge", challenge_index, "name") };
 | 
						|
    character.beginning(curve(), challenge_name != "LEVEL SELECT" && !is_replay);
 | 
						|
    coin_collected = false;
 | 
						|
    coin_returned = false;
 | 
						|
    let_go = false;
 | 
						|
 | 
						|
    /* Set the profile index to  match the value stored in the progress. Replays can override this temporarily after the
 | 
						|
     * level loads, and then it will be reset here once the replay is exited. */
 | 
						|
    if (profile_index != progress<int>("current difficulty"))
 | 
						|
    {
 | 
						|
        profile_index = progress<int>("current difficulty");
 | 
						|
        character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
    }
 | 
						|
 | 
						|
    /* Populate bank HUD so the proper coin will indicate the current level */
 | 
						|
    populate_bank_ui();
 | 
						|
 | 
						|
    /* The wrap space of the field is necessary for flame enemy objects */
 | 
						|
    sb::Box field {-curve().aspect, -1.0f, 2.0f * curve().aspect, 2.0f};
 | 
						|
 | 
						|
    /* Time out end screen */
 | 
						|
    if (static_cast<std::size_t>(index) == configuration()("levels").size() - 1)
 | 
						|
    {
 | 
						|
        submit_score_animation.play_once(configuration()("display", "end screen timeout"));
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        submit_score_animation.reset();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Reset enemies list to empty. Open configuration for the current level. Repopulate list of enemies one by one
 | 
						|
     * using the list of enemies in the configuration. For each enemy, add a challenge coin if the config specifies the
 | 
						|
     * coin parameters.
 | 
						|
     *
 | 
						|
     * Values read from the config are in some cases converted from old 25fps hard-coded per-frame values to per-second
 | 
						|
     * values, and hard-coded 864px by 486px pixel space to relative NDC space.
 | 
						|
     */
 | 
						|
    this->enemies.clear();
 | 
						|
    if (configuration()("levels", index).contains("enemies"))
 | 
						|
    {
 | 
						|
        nlohmann::json enemies = configuration()("levels", index, "enemies");
 | 
						|
        for (std::size_t ii = 0; ii < enemies.size(); ii++)
 | 
						|
        {
 | 
						|
            nlohmann::json enemy = enemies[ii];
 | 
						|
            std::string type = enemy[0];
 | 
						|
            if (type == "slicer")
 | 
						|
            {
 | 
						|
                std::shared_ptr<Slicer> slicer = std::make_shared<Slicer>(
 | 
						|
                    slicer_sprite, curve(), enemy[1].get<float>(), 2.0f * 25.0f * enemy[2].get<float>() / 486.0f,
 | 
						|
                    2.0f * enemy[3].get<float>() / 486.0f);
 | 
						|
 | 
						|
                /* Add coin to slicer */
 | 
						|
                if (enemy.size() > 4)
 | 
						|
                {
 | 
						|
                    slicer->coin(coin, enemy[4].get<float>(), enemy[5].get<float>());
 | 
						|
                }
 | 
						|
                
 | 
						|
                this->enemies.push_back(slicer);
 | 
						|
            }
 | 
						|
            else if (type == "fish")
 | 
						|
            {
 | 
						|
                std::shared_ptr<Fish> fish = std::make_shared<Fish>(
 | 
						|
                    fish_sprite, curve(), enemy[1].get<float>(), 25.0f * enemy[2].get<float>(),
 | 
						|
                    2.0f * enemy[3].get<float>() / 486.0f, enemy[4].get<float>());
 | 
						|
 | 
						|
                /* Add coin to fish */
 | 
						|
                if (enemy.size() > 6)
 | 
						|
                {
 | 
						|
                    fish->coin(coin, enemy[5].get<float>(), enemy[6].get<float>());
 | 
						|
                }
 | 
						|
                else if (enemy.size() > 5)
 | 
						|
                {
 | 
						|
                    fish->coin(coin, enemy[5].get<float>());
 | 
						|
                }
 | 
						|
 | 
						|
                /* One fish in level 15 is the wandering fish that unlocks an achievement. */
 | 
						|
                if (index == 15 && ii == 8)
 | 
						|
                {
 | 
						|
                    fish->wanderer = true;
 | 
						|
                }
 | 
						|
 | 
						|
                this->enemies.push_back(fish);
 | 
						|
            }
 | 
						|
            else if (type == "projector")
 | 
						|
            {
 | 
						|
                std::shared_ptr<Projector> projector = std::make_shared<Projector>(
 | 
						|
                    projector_sprite, projectile_sprite, character,
 | 
						|
                    (glm::vec3{2.0f * 1.7777f, 2.0f, 1.0f} * enemy[1].get<glm::fvec3>() /
 | 
						|
                     glm::vec3{864.0f, 486.0f, 1.0f} - glm::vec3(1.77777f, 1.0f, 0.0f)) * glm::vec3(1.0f, -1.0f, 0.0f),
 | 
						|
                    2.0f * 25.0f * enemy[2].get<float>() / 486.0, enemy[3].get<float>(), enemy[4].get<float>());
 | 
						|
 | 
						|
                /* Add coin to projector */
 | 
						|
                if (enemy.size() > 5)
 | 
						|
                {
 | 
						|
                    projector->coin(coin, enemy[5].get<float>());
 | 
						|
                }
 | 
						|
 | 
						|
                this->enemies.push_back(projector);
 | 
						|
            }
 | 
						|
            else if (type == "flame")
 | 
						|
            {
 | 
						|
                std::shared_ptr<Flame> flame = std::make_shared<Flame>(
 | 
						|
                    flame_sprite, field, enemy[1].get<glm::fvec3>(), enemy[2].get<float>(), enemy[3].get<float>(),
 | 
						|
                    enemy[4].get<float>());
 | 
						|
 | 
						|
                /* Add coin to flame */
 | 
						|
                if (enemy.size() > 5)
 | 
						|
                {
 | 
						|
                    flame->coin(coin, enemy[5], enemy[6]);
 | 
						|
                }
 | 
						|
 | 
						|
                this->enemies.push_back(flame);
 | 
						|
            }
 | 
						|
            else if (type == "grid")
 | 
						|
            {
 | 
						|
                /* Add a grid of flame objects */
 | 
						|
                float y = field.top();
 | 
						|
                glm::vec2 margin {0.59259f, 0.5f};
 | 
						|
                bool shift = false;
 | 
						|
                int count = 0;
 | 
						|
                while (y > field.bottom())
 | 
						|
                {
 | 
						|
                    float x = field.left() + shift * margin.x / 2.0f;
 | 
						|
                    while (x < field.right())
 | 
						|
                    {
 | 
						|
                        std::shared_ptr<Flame> flame = std::make_shared<Flame>(
 | 
						|
                            flame_sprite, field, glm::vec3{x, y, 0.0f}, 0.41152263f, glm::quarter_pi<float>());
 | 
						|
 | 
						|
                        /* Add a challenge coin */
 | 
						|
                        if (++count == 15)
 | 
						|
                        {
 | 
						|
                            flame->coin(coin, margin.x / 2.0f, 1.57f);
 | 
						|
                        }
 | 
						|
 | 
						|
                        this->enemies.push_back(flame);
 | 
						|
                        x += margin.x;
 | 
						|
                    }
 | 
						|
                    shift = !shift;
 | 
						|
                    y -= margin.y;
 | 
						|
                }
 | 
						|
            }
 | 
						|
            else if (type == "wave")
 | 
						|
            {
 | 
						|
                /* Add a wave of flame objects */
 | 
						|
                float y = 0.0f;
 | 
						|
                float speed = enemy[4].get<float>();
 | 
						|
                float amplitude = enemy[1].get<float>();
 | 
						|
                float period = enemy[2].get<float>();
 | 
						|
                float step = enemy[3].get<float>();
 | 
						|
                float shift = enemy[5].get<float>();
 | 
						|
                float mirror = -1.0f;
 | 
						|
                if (enemy.size() > 7)
 | 
						|
                {
 | 
						|
                    mirror = enemy[7].get<float>();
 | 
						|
                }
 | 
						|
                glm::vec2 range {field.left(), field.right()};
 | 
						|
                if (enemy.size() > 6)
 | 
						|
                {
 | 
						|
                    range = enemy[6].get<glm::vec2>();
 | 
						|
                }
 | 
						|
                float x = range.x;
 | 
						|
                for (std::size_t count = 0; x < range.y; ++count)
 | 
						|
                {
 | 
						|
                    y = amplitude * glm::sin(period * x) + shift;
 | 
						|
                    std::shared_ptr<Flame> flame = std::make_shared<Flame>(
 | 
						|
                        flame_sprite, field, glm::vec3{x, y, 0.0f}, speed, 3.0f * glm::half_pi<float>(), mirror);
 | 
						|
                    if (enemy.size() > 8 && enemy[8].get<std::size_t>() == count)
 | 
						|
                    {
 | 
						|
                        flame->coin(coin, enemy[9].get<float>(), enemy[10].get<float>());
 | 
						|
                    }
 | 
						|
                    this->enemies.push_back(flame);
 | 
						|
                    x += step;
 | 
						|
                } } } }
 | 
						|
 | 
						|
    /* If the level is the end screen, reset the player's current level to the beginning and load ending screen coin
 | 
						|
     * list. Unlock any new difficulty or view. Set a list of messages to be displayed on the end screen. */
 | 
						|
    if (end_screen())
 | 
						|
    {
 | 
						|
        /* Load ending coins */
 | 
						|
        ending_coins.clear();
 | 
						|
        glm::vec2 coin_range = configuration()("ending", "coin range").get<glm::vec2>();
 | 
						|
        float coin_step = (coin_range.y - coin_range.x) / (bank_count() - 1);
 | 
						|
        for (std::size_t ii = 0; ii < bank_count(); ii++)
 | 
						|
        {
 | 
						|
            Flame coin {
 | 
						|
                bank_count() < max_bank() ? flame_coin_sprite : flame_sprite,
 | 
						|
                field,
 | 
						|
                glm::vec3{coin_range.x + coin_step * ii, configuration()("ending", "coin y").get<float>(), 0.0f},
 | 
						|
                0.0f,
 | 
						|
                0.0f,
 | 
						|
                -1.0f,
 | 
						|
                bank_count() < max_bank()
 | 
						|
            };
 | 
						|
            ending_coins.push_back(coin);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Clear list of ending messages */
 | 
						|
        ending_messages.clear();
 | 
						|
 | 
						|
        /* Show the end for any run that beats all the levels */
 | 
						|
        if (quest() ||
 | 
						|
            (arcade() && configuration()("progress", "arcade level") >= configuration()("levels").size() - 2))
 | 
						|
        {
 | 
						|
            sb::Text message {fonts.at("glyph large"), configuration()("ending", "end text"),
 | 
						|
                              configuration()("ending", "messages foreground").get<glm::vec4>()};
 | 
						|
            message.dimensions(configuration()("ending", "messages dimensions"));
 | 
						|
            message.refresh();
 | 
						|
            ending_messages.push_back(message);
 | 
						|
 | 
						|
            /* Track complete quest */
 | 
						|
            if (quest())
 | 
						|
            {
 | 
						|
                stat_progress.increment_stat(stats["STAT_QUESTS_COMPLETED"], 1, achievements, stats);
 | 
						|
            }
 | 
						|
 | 
						|
            /* Track the no deaths achievement */
 | 
						|
            if ((quest() && progress<int>("quest deaths") == 0) || (arcade() && progress<int>("arcade deaths") == 0))
 | 
						|
            {
 | 
						|
                stat_progress.unlock_achievement(achievements["ACH_SLICE_OF_LIFE"]);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Unlocks for getting all coins */
 | 
						|
        if (bank_count() >= max_bank())
 | 
						|
        {
 | 
						|
            if (configuration()("progress", "max view").get<int>() < 1)
 | 
						|
            {
 | 
						|
                configuration()["progress"]["max view"] = 1;
 | 
						|
                sb::Text message {fonts.at("glyph"), configuration()("ending", "unlock mirror"),
 | 
						|
                                  configuration()("ending", "messages foreground").get<glm::vec4>()};
 | 
						|
                message.dimensions(configuration()("ending", "messages dimensions"));
 | 
						|
                message.refresh();
 | 
						|
                ending_messages.push_back(message);
 | 
						|
            }
 | 
						|
            if (configuration()("progress", "max view").get<int>() < 2 && profile_index >= 1)
 | 
						|
            {
 | 
						|
                configuration()["progress"]["max view"] = 2;
 | 
						|
                sb::Text message {fonts.at("glyph"), configuration()("ending", "unlock warped"),
 | 
						|
                                  configuration()("ending", "messages foreground").get<glm::vec4>()};
 | 
						|
                message.dimensions(configuration()("ending", "messages dimensions"));
 | 
						|
                message.refresh();
 | 
						|
                ending_messages.push_back(message);
 | 
						|
            }
 | 
						|
 | 
						|
            /* If all the coins were collected in BUFFALO BEEF CAKE mode, unlock the Jackpot */
 | 
						|
            if (configuration()("progress", "jackpot") != 777 && profile_index == 2)
 | 
						|
            {
 | 
						|
                configuration()["progress"]["jackpot"] = 777;
 | 
						|
                character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
                sb::Text message {fonts.at("glyph"), configuration()("ending", "unlock jackpot"),
 | 
						|
                                  configuration()("ending", "messages foreground").get<glm::vec4>()};
 | 
						|
                message.dimensions(configuration()("ending", "messages dimensions"));
 | 
						|
                message.refresh();
 | 
						|
                ending_messages.push_back(message);
 | 
						|
 | 
						|
                /* Record a cake unlock */
 | 
						|
                stat_progress.set_stat(stats["STAT_CAKES_UNLOCKED"], profile_index + 1, achievements, stats);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        configuration()["progress"]["current level"] = 1;
 | 
						|
 | 
						|
        /* Update save progress */
 | 
						|
        if (arcade())
 | 
						|
        {
 | 
						|
            configuration()["progress"]["arcade level"] = 1;
 | 
						|
            configuration()["progress"]["arcade checkpoint"] = 0.0f;
 | 
						|
            configuration()["progress"]["arcade max distance"] = 0;
 | 
						|
            configuration()["progress"]["arcade time"] = 0.0f;
 | 
						|
            configuration()["progress"]["arcade deaths"] = 0;
 | 
						|
            challenge_index = 4;
 | 
						|
            configuration()["progress"]["current challenge"] = challenge_index;
 | 
						|
        }
 | 
						|
        else if (quest())
 | 
						|
        {
 | 
						|
            configuration()["progress"]["quest level"] = 1;
 | 
						|
            configuration()["progress"]["quest checkpoint"] = 0.0f;
 | 
						|
            configuration()["progress"]["quest time"] = 0.0f;
 | 
						|
            configuration()["progress"]["quest deaths"] = 0;
 | 
						|
            challenge_index = 1;
 | 
						|
            configuration()["progress"]["current challenge"] = challenge_index;
 | 
						|
        }
 | 
						|
 | 
						|
        /* In quest mode, unlock higher difficulty at the end of the game: increase difficulty if there is a higher difficulty
 | 
						|
         * than the current one. Then reset the max level because it will now refer to max level of the next difficulty. Save
 | 
						|
         * the time if it is better than the existing record. */
 | 
						|
        if (quest())
 | 
						|
        {
 | 
						|
            /* If BUFFALO BEEF CAKE is already unlocked, there is nothing to do because the only unlocks left are based
 | 
						|
             * on coins */
 | 
						|
            if (profile_index < static_cast<int>(configuration()("character", "profile").size()) - 1)
 | 
						|
            {
 | 
						|
                /* If the end is reached with the profile that is currently the maximum profile available, a new profile
 | 
						|
                 * and difficulty are unlocked. */
 | 
						|
                if (profile_index == configuration()("progress", "max difficulty"))
 | 
						|
                {
 | 
						|
                    sb::Text message {fonts.at("glyph")};
 | 
						|
                    message.foreground(configuration()("ending", "messages foreground").get<glm::vec4>());
 | 
						|
                    profile_index == 0 ? message.content(configuration()("ending", "unlock beef")) :
 | 
						|
                        message.content(configuration()("ending", "unlock buffalo"));
 | 
						|
                    message.dimensions(configuration()("ending", "messages dimensions"));
 | 
						|
                    message.refresh();
 | 
						|
                    ending_messages.push_back(message);
 | 
						|
 | 
						|
                    /* Record a cake unlock */
 | 
						|
                    stat_progress.set_stat(stats["STAT_CAKES_UNLOCKED"], profile_index + 1, achievements, stats);
 | 
						|
                }
 | 
						|
 | 
						|
                configuration()["progress"]["max difficulty"] = ++profile_index;
 | 
						|
                configuration()["progress"]["max level"] = 1;
 | 
						|
                character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
 | 
						|
                /* Reset resume game because the run is complete. The level is reset to 1 above. The difficulty should
 | 
						|
                 * also increase, making a new game at the next difficulty the new default game. */
 | 
						|
                configuration()["progress"]["quest difficulty"] = profile_index;
 | 
						|
 | 
						|
                /* Update currently selected difficulty, so the replay reset of difficulty in this function isn't
 | 
						|
                 * triggered when loading the title screen. */
 | 
						|
                configuration()["progress"]["current difficulty"] = profile_index;
 | 
						|
            }
 | 
						|
 | 
						|
            float best = configuration()("progress", "quest best");
 | 
						|
            if (best <= 0.0f || run_timer_save < best)
 | 
						|
            {
 | 
						|
                configuration()["progress"]["quest best"] = run_timer_save;
 | 
						|
                label.at("quest best").content(
 | 
						|
                    configuration()("display", "quest best text").get<std::string>() + " " +
 | 
						|
                    format_clock(run_timer_save));
 | 
						|
                label.at("quest best").refresh();
 | 
						|
                sb::Text message {fonts.at("glyph"), configuration()("ending", "new best"),
 | 
						|
                                  configuration()("ending", "messages foreground").get<glm::vec4>()};
 | 
						|
                message.dimensions(configuration()("ending", "messages dimensions"));
 | 
						|
                message.refresh();
 | 
						|
                ending_messages.push_back(message);
 | 
						|
 | 
						|
                /* Record stat */
 | 
						|
                if (!stat_progress.stat_exists(stats["STAT_FASTEST_QUEST_TIME"]) ||
 | 
						|
                    run_timer_save < stat_progress.stat_value(stats["STAT_FASTEST_QUEST_TIME"]))
 | 
						|
                {
 | 
						|
                    stat_progress.set_stat(
 | 
						|
                        stats["STAT_FASTEST_QUEST_TIME"], run_timer_save, achievements, stats);
 | 
						|
                }
 | 
						|
 | 
						|
                /* Unlock speed achievements */
 | 
						|
                if (run_timer_save <= 60.0f * 60.0f)
 | 
						|
                {
 | 
						|
                    stat_progress.unlock_achievement(achievements["ACH_KNEAD_FOR_SPEED"]);
 | 
						|
                    if (run_timer_save <= 30.0f * 60.0f)
 | 
						|
                    {
 | 
						|
                        stat_progress.unlock_achievement(achievements["ACH_CAKEFEAT"]);
 | 
						|
                    } } } }
 | 
						|
 | 
						|
        progress_writer.stage(true, true);
 | 
						|
        level_select_index = 1;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Otherwise, if the level is not the title screen, save it as the current level in the player's progress. Also save
 | 
						|
     * the newly assigned current level in the level select index. Skip this section when a replay is launching. */
 | 
						|
    else if (index > 0 && !is_replay)
 | 
						|
    {
 | 
						|
        /* Unlock the level if it is a newly reached level */
 | 
						|
        if (configuration()("progress", "max difficulty") == profile_index &&
 | 
						|
            configuration()("progress", "max level").get<int>() < index)
 | 
						|
        {
 | 
						|
            configuration()["progress"]["max level"] = index;
 | 
						|
        }
 | 
						|
 | 
						|
        /* Update stat if necessary */
 | 
						|
        if (stat_progress.stat_default(stats["STAT_LEVELS_UNLOCKED"], 1, stats) < index)
 | 
						|
        {
 | 
						|
            stat_progress.set_stat(stats["STAT_LEVELS_UNLOCKED"], index, achievements, stats);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Read and write save progress */
 | 
						|
        if (arcade())
 | 
						|
        {
 | 
						|
            if (configuration()("progress", "arcade level") == index)
 | 
						|
            {
 | 
						|
                /* If resuming, set the clock and checkpoint */
 | 
						|
                run_timer.elapsed(configuration()("progress", "arcade time").get<float>());
 | 
						|
                character.checkpoint(configuration()("progress", "arcade checkpoint").get<float>());
 | 
						|
                character.spawn(curve());
 | 
						|
                if (bank()[index - 1])
 | 
						|
                {
 | 
						|
                    for (auto& enemy : enemies) enemy->take_coin();
 | 
						|
                    collect_coin();
 | 
						|
                }
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                configuration()["progress"]["arcade level"] = index;
 | 
						|
                configuration()["progress"]["arcade checkpoint"] = 0;
 | 
						|
                configuration()["progress"]["arcade difficulty"] = profile_index;
 | 
						|
                configuration()["progress"]["arcade max distance"] = distance();
 | 
						|
            }
 | 
						|
        }
 | 
						|
        else if (quest())
 | 
						|
        {
 | 
						|
            if (configuration()("progress", "quest level") == index)
 | 
						|
            {
 | 
						|
                /* If resuming, set the checkpoint and clock */
 | 
						|
                run_timer.elapsed(configuration()("progress", "quest time").get<float>());
 | 
						|
                character.checkpoint(configuration()("progress", "quest checkpoint").get<float>());
 | 
						|
                character.spawn(curve());
 | 
						|
                if (bank()[index - 1])
 | 
						|
                {
 | 
						|
                    for (auto& enemy : enemies) enemy->take_coin();
 | 
						|
                    collect_coin();
 | 
						|
                }
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                configuration()["progress"]["quest level"] = index;
 | 
						|
                configuration()["progress"]["quest checkpoint"] = 0;
 | 
						|
                configuration()["progress"]["quest difficulty"] = profile_index;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        configuration()["progress"]["current level"] = index;
 | 
						|
        progress_writer.stage(true, true);
 | 
						|
        level_select_index = index;
 | 
						|
    }
 | 
						|
 | 
						|
    /* In demo mode, reset the challenge to new quest every time the title is loaded */
 | 
						|
    if (configuration()("demo", "active") && index == 0)
 | 
						|
    {
 | 
						|
        challenge_index = 1;
 | 
						|
        level_select_index = 1;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Refresh HUD elements */
 | 
						|
    if (index == 0 || end_screen())
 | 
						|
    {
 | 
						|
        set_up_hud();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Set the color background and music according to current world. Use world 0 color for the end screen. */
 | 
						|
    if (index > 0)
 | 
						|
    {
 | 
						|
        bgm.set(world_index(index));
 | 
						|
    }
 | 
						|
    world_color = configuration()("world")[world_index(index)].at("color").get<glm::fvec4>();
 | 
						|
 | 
						|
    /* Flash the screen at the start of a level */
 | 
						|
    flash_animation.play_once(configuration()("display", "flash length"));
 | 
						|
 | 
						|
    /* Load the appropriate background shader and uniforms based on the world index and sub index. For the title screen,
 | 
						|
     * choose a random level's shader. */
 | 
						|
    if (index == 0)
 | 
						|
    {
 | 
						|
        use_shader_by_level(sb::math::random::integer(1, configuration()("levels").size() - 2));
 | 
						|
 | 
						|
        /* Start title name effect from the beginning */
 | 
						|
        title_name_animation.reset();
 | 
						|
        title_name_animation.play();
 | 
						|
        title_name_animation.immediately();
 | 
						|
        title_name_animation_voice_played = false;
 | 
						|
        title_name_animation_buildup_played = false;
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        use_shader_by_level(index);
 | 
						|
    }
 | 
						|
 | 
						|
#if defined(__COOLMATH__)
 | 
						|
    /* Send a game event to the Coolmath API if it's a regular level. */
 | 
						|
    if (index > 0 && static_cast<std::size_t>(index) < configuration()("levels").size() - 1)
 | 
						|
    {
 | 
						|
        EM_ASM(
 | 
						|
            {
 | 
						|
                if (parent.cmgGameEvent !== undefined)
 | 
						|
                {
 | 
						|
                    console.log("cmgGameEvent start " + $0);
 | 
						|
                    parent.cmgGameEvent("start", $0);
 | 
						|
                }
 | 
						|
            }, index);
 | 
						|
    }
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Reset distance tracking */
 | 
						|
    previous_distance.reset();
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::write_progress(
 | 
						|
    bool force_save_stats,
 | 
						|
    [[maybe_unused]] bool force_sync_stats,
 | 
						|
    [[maybe_unused]] bool force_sync_session)
 | 
						|
{
 | 
						|
    /* Create directory for save files */
 | 
						|
    if (!fs::exists("storage"))
 | 
						|
    {
 | 
						|
        try
 | 
						|
        {
 | 
						|
            fs::create_directory("storage");
 | 
						|
        }
 | 
						|
        catch (const fs::filesystem_error& error)
 | 
						|
        {
 | 
						|
            std::ostringstream message;
 | 
						|
            message << "Could not create storage directory. Progress will not be saved. " << error.what();
 | 
						|
            sb::Log::log(message, sb::Log::ERR);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Save player's progress file */
 | 
						|
    fs::path progress_file_path {configuration()("storage", "progress file")};
 | 
						|
    std::ofstream progress_file {progress_file_path};
 | 
						|
    nlohmann::json progress = nlohmann::json({
 | 
						|
        {"progress", configuration()("progress")}
 | 
						|
    });
 | 
						|
    if (progress_file << std::setw(4) << progress << std::endl)
 | 
						|
    {
 | 
						|
        sb::Log::Multi() << "Successfully saved progress to " << progress_file_path << sb::Log::end;
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        sb::Log::Multi(sb::Log::ERR) << "Could not save progress to " << progress_file_path << sb::Log::end;
 | 
						|
    }
 | 
						|
    progress_file.close();
 | 
						|
 | 
						|
#if defined(EMSCRIPTEN)
 | 
						|
    EM_ASM(
 | 
						|
        /* Only run a new sync if a sync isn't already running. */
 | 
						|
        FS.syncfs(false, function(error) {
 | 
						|
                if (error !== null)
 | 
						|
                {
 | 
						|
                    console.log("Error syncing storage using Filesystem API", error);
 | 
						|
                }
 | 
						|
            });
 | 
						|
        );
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Save stats and mark as saved */
 | 
						|
    if (save_stats || force_save_stats)
 | 
						|
    {
 | 
						|
        fs::path path = configuration()("storage", "stats file");
 | 
						|
        save_stats = false;
 | 
						|
        stat_progress.save(path);
 | 
						|
    }
 | 
						|
 | 
						|
#if defined(STEAM_ENABLED)
 | 
						|
    /* Sync stats with Steam and mark as synced */
 | 
						|
    if (sb::cloud::steam::initialized() && (sync_stats || force_sync_stats))
 | 
						|
    {
 | 
						|
        sync_stats = false;
 | 
						|
        sb::cloud::steam::store_stats();
 | 
						|
    }
 | 
						|
#endif
 | 
						|
 | 
						|
#if defined(HTTP_ENABLED)
 | 
						|
    /* Sync session data with the remote logger if one is configured */
 | 
						|
    if (http.initialized()
 | 
						|
        && (sync_session || force_sync_session)
 | 
						|
        && !configuration()("session", "receiver").value("url", "").empty())
 | 
						|
    {
 | 
						|
        sync_session = false;
 | 
						|
        std::string authorization { configuration()("session", "receiver").value("authorization", "") };
 | 
						|
 | 
						|
        /* Send the session data to the configured receiver URL */
 | 
						|
        http.post_analytics(
 | 
						|
            configuration()("session", "receiver", "url"),
 | 
						|
            stat_progress.session(),
 | 
						|
            configuration()("session", "title"),
 | 
						|
            cakefoot::version,
 | 
						|
            configuration()("session").value("platform", ""),
 | 
						|
            stat_progress.id(),
 | 
						|
            authorization);
 | 
						|
    }
 | 
						|
#endif
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::write_scores() const
 | 
						|
{
 | 
						|
    /* Create directory for save files */
 | 
						|
    if (!fs::exists("storage"))
 | 
						|
    {
 | 
						|
        try
 | 
						|
        {
 | 
						|
            fs::create_directory("storage");
 | 
						|
        }
 | 
						|
        catch (const fs::filesystem_error& error)
 | 
						|
        {
 | 
						|
            sb::Log::Multi(sb::Log::ERR) << "Could not create storage directory. Scores will not be saved. " <<
 | 
						|
                error.what() << sb::Log::end;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Save scores */
 | 
						|
    fs::path arcade_scores_file_path {configuration()("storage", "scores file")};
 | 
						|
    std::ofstream arcade_scores_file {arcade_scores_file_path};
 | 
						|
    if (arcade_scores_file << std::setw(4) << arcade_scores.json(date_format) << std::endl)
 | 
						|
    {
 | 
						|
        std::ostringstream message;
 | 
						|
        message << "Successfully saved arcade scores to " << arcade_scores_file_path;
 | 
						|
        sb::Log::log(message);
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        std::ostringstream message;
 | 
						|
        message << "Could not save arcade scores to " << arcade_scores_file_path;
 | 
						|
        sb::Log::log(message, sb::Log::ERR);
 | 
						|
    }
 | 
						|
    arcade_scores_file.close();
 | 
						|
 | 
						|
#if defined(EMSCRIPTEN)
 | 
						|
    EM_ASM(
 | 
						|
        /* Only run a new sync if a sync isn't already running. */
 | 
						|
        FS.syncfs(false, function(error) {
 | 
						|
                if (error !== null)
 | 
						|
                {
 | 
						|
                    console.log("Error syncing storage using Filesystem API", error);
 | 
						|
                }
 | 
						|
            });
 | 
						|
        );
 | 
						|
#endif
 | 
						|
}
 | 
						|
 | 
						|
int Cakefoot::length() const
 | 
						|
{
 | 
						|
    int length = 0;
 | 
						|
 | 
						|
    /* Ignore the title and end levels */
 | 
						|
    for (auto curve = curves.begin() + 1; curve != curves.end() - 1; curve++) length += curve->length();
 | 
						|
 | 
						|
    return length;
 | 
						|
}
 | 
						|
 | 
						|
int Cakefoot::distance() const
 | 
						|
{
 | 
						|
    int distance = 0;
 | 
						|
 | 
						|
    if (level_index > 0) {
 | 
						|
 | 
						|
        distance += int(character.relative(curve()) * curve().length());
 | 
						|
        if (level_index > 1)
 | 
						|
            for (auto curve = curves.begin() + 1;
 | 
						|
                 curve != curves.begin() + level_index && curve != curves.end() - 1;
 | 
						|
                 curve++)
 | 
						|
            {
 | 
						|
                distance += curve->length(); } }
 | 
						|
 | 
						|
    return distance;
 | 
						|
}
 | 
						|
 | 
						|
float Cakefoot::distance_float() const
 | 
						|
{
 | 
						|
    float distance = 0;
 | 
						|
 | 
						|
    if (level_index > 0) {
 | 
						|
 | 
						|
        distance += character.relative(curve()) * curve().length();
 | 
						|
        if (level_index > 1)
 | 
						|
            for (auto curve = curves.begin() + 1;
 | 
						|
                 curve != curves.begin() + level_index && curve != curves.end() - 1;
 | 
						|
                 curve++)
 | 
						|
            {
 | 
						|
                distance += curve->length(); } }
 | 
						|
 | 
						|
    return distance;
 | 
						|
}
 | 
						|
 | 
						|
float Cakefoot::limit() const
 | 
						|
{
 | 
						|
    if (arcade())
 | 
						|
    {
 | 
						|
        float limit = configuration()("challenge", challenge_index, "time limit");
 | 
						|
 | 
						|
        if (level_index > 0)
 | 
						|
        {
 | 
						|
            const nlohmann::json& levels = configuration()("levels");
 | 
						|
            for (auto level = levels.begin() + 1; level != levels.begin() + level_index + 1; level++) {
 | 
						|
                std::string level_addition = "level addition";
 | 
						|
                std::string checkpoint_addition = "checkpoint addition";
 | 
						|
                if (level >= levels.begin() + configuration()("challenge", challenge_index, "advanced").get<int>())
 | 
						|
                {
 | 
						|
                    level_addition += " advanced";
 | 
						|
                    checkpoint_addition += " advanced";
 | 
						|
                }
 | 
						|
                if (level < levels.begin() + level_index)
 | 
						|
                {
 | 
						|
                    limit += configuration()("challenge", challenge_index, level_addition).get<float>();
 | 
						|
                    if (level->contains("checkpoints"))
 | 
						|
                        limit += configuration()("challenge", challenge_index, checkpoint_addition).get<float>() *
 | 
						|
                            level->at("checkpoints").size();
 | 
						|
                }
 | 
						|
                else if (level->contains("checkpoints"))
 | 
						|
                {
 | 
						|
                    for (const nlohmann::json& checkpoint : level->at("checkpoints"))
 | 
						|
                    {
 | 
						|
                        if (checkpoint.at("position").get<float>() <= character.checkpoint())
 | 
						|
                            limit += configuration()("challenge", challenge_index, checkpoint_addition).get<float>();
 | 
						|
                    } } }
 | 
						|
 | 
						|
            /* Add bank bonus */
 | 
						|
            limit += bank_count() * configuration()("challenge", challenge_index, "bank bonus").get<float>();
 | 
						|
        }
 | 
						|
 | 
						|
        return limit;
 | 
						|
    }
 | 
						|
    else return 0.0f;
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::arcade() const
 | 
						|
{
 | 
						|
    return configuration()("challenge", challenge_index).contains("time limit");
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::quest() const
 | 
						|
{
 | 
						|
    return !arcade() && !level_select();
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::level_select() const
 | 
						|
{
 | 
						|
    return !arcade() && configuration()("challenge", challenge_index, "name") == "LEVEL SELECT";
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::end_screen(std::optional<std::size_t> index) const
 | 
						|
{
 | 
						|
    if (!index.has_value())
 | 
						|
    {
 | 
						|
        index = level_index;
 | 
						|
    }
 | 
						|
    return static_cast<std::size_t>(index.value()) == _configuration("levels").size() - 1;
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::resuming() const
 | 
						|
{
 | 
						|
    return configuration()("challenge", challenge_index, "name") == "RESUME QUEST" ||
 | 
						|
        configuration()("challenge", challenge_index, "name") == "RESUME ARCADE";
 | 
						|
}
 | 
						|
 | 
						|
std::vector<bool> Cakefoot::bank() const
 | 
						|
{
 | 
						|
    std::vector<bool> bank { bank_parse(bank_serialized()) };
 | 
						|
    return bank;
 | 
						|
}
 | 
						|
 | 
						|
std::string Cakefoot::bank_serialized(const std::vector<bool>& bank) const
 | 
						|
{
 | 
						|
    if (bank.empty())
 | 
						|
    {
 | 
						|
        nlohmann::json bank = configuration()("progress", quest() ? "quest bank" : "arcade bank");
 | 
						|
        /* For backward compatibility, support the bank being saved as a number and convert it so that the first N
 | 
						|
         * characters of the string representation indicate a collected coin. Although this won't be correct in terms of
 | 
						|
         * which levels were collected, the count will be accurate. Replace the older number format with the newer
 | 
						|
         * string format in the progress when done. */
 | 
						|
        if (bank.is_number())
 | 
						|
        {
 | 
						|
            std::string bank_converted = "";
 | 
						|
            for (std::size_t ii = 0; ii < bank_init().size(); ii++)
 | 
						|
            {
 | 
						|
                bank_converted += configuration()("coin ui", ii < bank ? "collected text" : "uncollected text");
 | 
						|
            }
 | 
						|
            bank = bank_converted;
 | 
						|
            return bank_converted;
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* Get the already serialized data directly from the save file */
 | 
						|
            return bank.get<std::string>();
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        /* Serialize the input */
 | 
						|
        std::string bank_serialized;
 | 
						|
        for (const bool level : bank)
 | 
						|
        {
 | 
						|
            bank_serialized += configuration()("coin ui", (level ? "collected text" : "uncollected text"));
 | 
						|
        }
 | 
						|
        return bank_serialized;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
std::vector<bool> Cakefoot::bank_init() const
 | 
						|
{
 | 
						|
    std::vector<bool> bank;
 | 
						|
    for (std::size_t ii = 0; ii < max_bank(); ii++)
 | 
						|
    {
 | 
						|
        bank.push_back(false);
 | 
						|
    }
 | 
						|
    return bank;
 | 
						|
}
 | 
						|
 | 
						|
std::size_t Cakefoot::bank_count() const
 | 
						|
{
 | 
						|
    std::size_t count = 0;
 | 
						|
    for (const bool level : bank())
 | 
						|
    {
 | 
						|
        count += level;
 | 
						|
    }
 | 
						|
    return count;
 | 
						|
}
 | 
						|
 | 
						|
std::size_t Cakefoot::max_bank() const
 | 
						|
{
 | 
						|
    return configuration()("levels").size() - 2;
 | 
						|
}
 | 
						|
 | 
						|
std::vector<bool> Cakefoot::bank_parse(const std::string& tokens) const
 | 
						|
{
 | 
						|
    std::vector<bool> bank;
 | 
						|
    for (std::size_t bank_ii = 0; bank_ii < max_bank(); bank_ii++)
 | 
						|
    {
 | 
						|
        if (bank_ii < tokens.size())
 | 
						|
        {
 | 
						|
            bank.push_back(
 | 
						|
                tokens[bank_ii] == configuration()("coin ui", "collected text").get<std::string>()[0] ? true : false);
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            bank.push_back(false);
 | 
						|
        }
 | 
						|
    }
 | 
						|
    return bank;
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::collect_coin()
 | 
						|
{
 | 
						|
    if (!coin_collected)
 | 
						|
    {
 | 
						|
        for (auto& enemy : enemies)
 | 
						|
        {
 | 
						|
            if (enemy->coin_taken())
 | 
						|
            {
 | 
						|
                enemy->collect_coin();
 | 
						|
                coin_collected = true;
 | 
						|
 | 
						|
                /* Get the bank out of the save file, convert to bool array, update the array, and re-serialize the bank
 | 
						|
                 * in the save file. */
 | 
						|
                std::vector<bool> current_bank = bank();
 | 
						|
                current_bank[level_index - 1] = true;
 | 
						|
                configuration()["progress"][quest() ? "quest bank" : "arcade bank"] = bank_serialized(current_bank);
 | 
						|
 | 
						|
                /* Parse the all time bank progress, update, and re-serialize */
 | 
						|
                current_bank = bank_parse(progress<nlohmann::json>("all time bank"));
 | 
						|
                current_bank[level_index - 1] = true;
 | 
						|
                configuration()["progress"]["all time bank"] = bank_serialized(current_bank);
 | 
						|
 | 
						|
                /* Count all time bank and update stat if necessary */
 | 
						|
                int count = 0;
 | 
						|
                for (bool level : current_bank)
 | 
						|
                {
 | 
						|
                    count += level;
 | 
						|
                }
 | 
						|
                if (count > stat_progress.stat_default(stats["STAT_COINS_UNLOCKED"], 0, stats))
 | 
						|
                {
 | 
						|
                    stat_progress.set_stat(stats["STAT_COINS_UNLOCKED"], count, achievements, stats);
 | 
						|
                }
 | 
						|
 | 
						|
                /* Count overall coins collected */
 | 
						|
                stat_progress.increment_stat(stats["STAT_COINS_COLLECTED"], 1, achievements, stats);
 | 
						|
 | 
						|
                /* Update the HUD */
 | 
						|
                populate_bank_ui();
 | 
						|
 | 
						|
                /* Record collect event to replay */
 | 
						|
                if (replay_recording)
 | 
						|
                {
 | 
						|
                    replay_recording->record({
 | 
						|
                            run_timer,
 | 
						|
                            character.translation(),
 | 
						|
                            character.mirrored(curve()),
 | 
						|
                            cakefoot::Replay::collect});
 | 
						|
                } } } }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::end_game_over_display()
 | 
						|
{
 | 
						|
    load_level(configuration()("levels").size() - 1);
 | 
						|
}
 | 
						|
 | 
						|
float Cakefoot::arcade_time_remaining(float limit) const
 | 
						|
{
 | 
						|
    return std::max(0.0f, limit - run_timer.elapsed());
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::submit_score()
 | 
						|
{
 | 
						|
    arcade_score.name = name_entry;
 | 
						|
    arcade_scores.add(arcade_score);
 | 
						|
    write_scores();
 | 
						|
    refresh_scoreboard();
 | 
						|
    load_level(0);
 | 
						|
    stat_progress.increment_stat(stats["STAT_ARCADE_RUNS_SUBMITTED"], 1, achievements, stats);
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::set_arcade_score(float extended_limit, int maximum_distance)
 | 
						|
{
 | 
						|
    arcade_score = ArcadeScores::Score {arcade_time_remaining(extended_limit), maximum_distance};
 | 
						|
    int rank = std::min(9999, arcade_scores.rank(arcade_score));
 | 
						|
    std::ostringstream rank_str, distance_str;
 | 
						|
    rank_str << rank;
 | 
						|
    if (rank == 1)
 | 
						|
    {
 | 
						|
        rank_str << "st";
 | 
						|
    }
 | 
						|
    else if (rank == 2)
 | 
						|
    {
 | 
						|
        rank_str << "nd";
 | 
						|
    }
 | 
						|
    else if (rank == 3)
 | 
						|
    {
 | 
						|
        rank_str << "rd";
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        rank_str << "th";
 | 
						|
    }
 | 
						|
    label.at("arcade rank").content(rank_str.str());
 | 
						|
    label.at("arcade rank").refresh();
 | 
						|
    distance_str << arcade_score.distance << "m";
 | 
						|
    label.at("arcade distance").content(distance_str.str());
 | 
						|
    label.at("arcade distance").refresh();
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::shift_hue()
 | 
						|
{
 | 
						|
    rotating_hue.shift_hue(configuration()("display", "hue shift").get<float>());
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::blink()
 | 
						|
{
 | 
						|
    blinking_visible = !blinking_visible;
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::next_splash()
 | 
						|
{
 | 
						|
    if (static_cast<std::size_t>(splash_index) < splash.size() - 1)
 | 
						|
    {
 | 
						|
        splash_index++;
 | 
						|
        splash_animation.play_once(splash[splash_index].length);
 | 
						|
        world_color = splash[splash_index].background.normal();
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        splash_animation.pause();
 | 
						|
        load_level(0);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::flash_warning()
 | 
						|
{
 | 
						|
    if (arcade() && run_timer && run_timer.elapsed() + configuration()("display", "arcade warning start").get<float>() > limit())
 | 
						|
    {
 | 
						|
        arcade_limit_warning = !arcade_limit_warning;
 | 
						|
 | 
						|
        /* Depth into the warning range determines the speed of the warning */
 | 
						|
        nlohmann::json frequency = configuration()("display", "arcade warning frequency");
 | 
						|
        float delay = (run_timer.elapsed() + configuration()("display", "arcade warning start").get<float>() - limit()) /
 | 
						|
            configuration()("display", "arcade warning start").get<float>() * (
 | 
						|
                frequency[1].get<float>() - frequency[0].get<float>()) + frequency[0].get<float>();
 | 
						|
        warning_animation.frame_length(delay);
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        arcade_limit_warning = false;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
std::string Cakefoot::format_clock(float amount)
 | 
						|
{
 | 
						|
    int minutes = int(amount) / 60;
 | 
						|
    float seconds = amount - (minutes * 60);
 | 
						|
    std::stringstream clock;
 | 
						|
    clock << std::setw(2) << std::setfill('0') << minutes << ":" << std::setw(4) << std::setprecision(1) << std::fixed << seconds;
 | 
						|
    return clock.str();
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::refresh_scoreboard()
 | 
						|
{
 | 
						|
    std::string text {arcade_scores.formatted(4, 4)};
 | 
						|
    scoreboard.content(text);
 | 
						|
    scoreboard.refresh();
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::respond(SDL_Event& event)
 | 
						|
{
 | 
						|
    sb::Game::respond(event);
 | 
						|
 | 
						|
#if !defined(__EMSCRIPTEN__)
 | 
						|
    /* On Emscripten builds, the fullscreen preference should not be saved because the game should always start in
 | 
						|
     * windowed mode */
 | 
						|
    if (sb::Delegate::compare(event, "fullscreen"))
 | 
						|
    {
 | 
						|
        /* Store the fullscreen state in the user's preferences and save preferences to disk */
 | 
						|
        preferences.config(display.fullscreen_status(), "display", "fullscreen");
 | 
						|
        preferences.save(configuration()("storage", "preferences file"));
 | 
						|
 | 
						|
        /* Full screen event is fully handled, exit event response function */
 | 
						|
        return;
 | 
						|
    }
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Increase player's credit amount. Clamp so it doesn't exceed max credits. */
 | 
						|
    if (sb::Delegate::compare(event, "add credit") && configuration()("arcade", "arcade only") &&
 | 
						|
        configuration()("arcade", "credits enabled"))
 | 
						|
    {
 | 
						|
        credits += configuration()("arcade", "credit increase per event").get<float>();
 | 
						|
 | 
						|
        /* Constrain to max if there is a max set (0 means no max) */
 | 
						|
        float max = configuration()("arcade", "max credits");
 | 
						|
        if (max > 0.0f && credits > max)
 | 
						|
        {
 | 
						|
            credits = max;
 | 
						|
        }
 | 
						|
 | 
						|
        /* Redraw prompt */
 | 
						|
        set_up_buttons();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Flip the operator mode flag on if it's enabled */
 | 
						|
    if (sb::Delegate::compare(event, "operator") && configuration()("operator", "enabled") && !operator_menu_active)
 | 
						|
    {
 | 
						|
        operator_menu_active = true;
 | 
						|
        confirming_new_quest = false;
 | 
						|
        confirming_new_arcade = false;
 | 
						|
 | 
						|
        /* Load on demand */
 | 
						|
        load_operator_menu();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Reopen gamepad if gamepad is detected as added or removed */
 | 
						|
    if (event.type == SDL_JOYDEVICEADDED || event.type == SDL_JOYDEVICEREMOVED)
 | 
						|
    {
 | 
						|
        sb::Log::log(std::string("Detected gamepad ") + (event.type == SDL_JOYDEVICEADDED ? "added" : "removed") +
 | 
						|
                     ". Reloading gamepad.");
 | 
						|
        controller.reset();
 | 
						|
        open_game_controller();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Reset the idle timer */
 | 
						|
    idle_timer.reset();
 | 
						|
 | 
						|
    /* Any input cancels attract mode and returns to the title screen. The rest of the response function should be
 | 
						|
     * skipped so that menu navigation isn't activated. */
 | 
						|
    if (attract_mode_active)
 | 
						|
    {
 | 
						|
        load_level(0);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Track whether cursor should be visible or not */
 | 
						|
    bool joy_or_key_input_registered = event.type == SDL_KEYDOWN;
 | 
						|
    bool mouse_input_registered = (event.type == SDL_MOUSEMOTION || event.type == SDL_MOUSEBUTTONDOWN);
 | 
						|
 | 
						|
    /* Translate gamepad input to commands */
 | 
						|
    if (event.type == SDL_JOYBUTTONDOWN)
 | 
						|
    {
 | 
						|
        /* Pause on either pause button or home button press */
 | 
						|
        if (level_index > 0 && static_cast<std::size_t>(level_index) <= configuration()("levels").size() - 2 &&
 | 
						|
            (event.jbutton.button == configuration()("input", "gamepad pause button index") ||
 | 
						|
             event.jbutton.button == configuration()("input", "gamepad home button index")))
 | 
						|
        {
 | 
						|
            sb::Delegate::post("pause");
 | 
						|
            joy_or_key_input_registered = true;
 | 
						|
        }
 | 
						|
        else if (configuration()("demo", "active") && level_index > 0 &&
 | 
						|
                 static_cast<std::size_t>(level_index) <= configuration()("levels").size() - 2 &&
 | 
						|
                 event.jbutton.button == configuration()("input", "gamepad reset button index"))
 | 
						|
        {
 | 
						|
            sb::Delegate::post("reset");
 | 
						|
        }
 | 
						|
        else if ((!use_play_button || button.at("play").pressed()) && !splash_animation.playing())
 | 
						|
        {
 | 
						|
            sb::Delegate::post("any");
 | 
						|
            joy_or_key_input_registered = true;
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else if (event.type == SDL_JOYBUTTONUP)
 | 
						|
    {
 | 
						|
        sb::Delegate::post("any", true);
 | 
						|
    }
 | 
						|
    else if ((event.type == SDL_JOYAXISMOTION || event.type == SDL_JOYHATMOTION) && !cooldown_animation.playing())
 | 
						|
    {
 | 
						|
        bool up = (event.type == SDL_JOYAXISMOTION && event.jaxis.axis == 1 && event.jaxis.value < -15000) ||
 | 
						|
            (event.type == SDL_JOYHATMOTION && event.jhat.value == SDL_HAT_UP);
 | 
						|
        bool right = (event.type == SDL_JOYAXISMOTION && event.jaxis.axis == 0 && event.jaxis.value > 15000) ||
 | 
						|
            (event.type == SDL_JOYHATMOTION && event.jhat.value == SDL_HAT_RIGHT);
 | 
						|
        bool down = (event.type == SDL_JOYAXISMOTION && event.jaxis.axis == 1 && event.jaxis.value > 15000) ||
 | 
						|
            (event.type == SDL_JOYHATMOTION && event.jhat.value == SDL_HAT_DOWN);
 | 
						|
        bool left = (event.type == SDL_JOYAXISMOTION && event.jaxis.axis == 0 && event.jaxis.value < -15000) ||
 | 
						|
            (event.type == SDL_JOYHATMOTION && event.jhat.value == SDL_HAT_LEFT);
 | 
						|
                                                                         
 | 
						|
        if (up) sb::Delegate::post("up");
 | 
						|
        if (right) sb::Delegate::post("right");
 | 
						|
        if (down) sb::Delegate::post("down");
 | 
						|
        if (left) sb::Delegate::post("left");
 | 
						|
 | 
						|
        if (up || right || down || left)
 | 
						|
        {
 | 
						|
            joy_or_key_input_registered = true;
 | 
						|
            cooldown_animation.play_once(configuration()("input", "gamepad axis cooldown"));
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Get mouse button states */
 | 
						|
    bool left_mouse_pressed = SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON_LMASK;
 | 
						|
    bool shift_pressed = SDL_GetModState() & KMOD_SHIFT;
 | 
						|
 | 
						|
    /* Get mouse coordinates in pixel resolution and NDC. These values are invalid if the event isn't a mouse event. */
 | 
						|
    glm::vec2 mouse_pixel = event.type == SDL_MOUSEBUTTONDOWN ? glm::vec2{event.button.x, event.button.y} :
 | 
						|
        glm::vec2{event.motion.x, event.motion.y};
 | 
						|
    glm::vec2 mouse_ndc {
 | 
						|
        float(mouse_pixel.x) / window_box().width() * 2.0f - 1.0f,
 | 
						|
        (1.0f - float(mouse_pixel.y) / window_box().height()) * 2.0f - 1.0f
 | 
						|
    };
 | 
						|
 | 
						|
    /* Track whether pointer or default cursor should display. Only reset to default cursor if mouse motion was the event.
 | 
						|
     * Otherwise, set hovering to the current state of the cursor. */
 | 
						|
    bool hovering = event.type != SDL_MOUSEMOTION && SDL_GetCursor() == poke.get();
 | 
						|
 | 
						|
    /* Build a list of title screen buttons currently active. */
 | 
						|
    std::vector<std::string> title_menu;
 | 
						|
    const nlohmann::json::string_t& challenge_name = configuration()("challenge", challenge_index, "name").
 | 
						|
        get_ref<const nlohmann::json::string_t&>();
 | 
						|
 | 
						|
    /* The start button is only displayed on quest and arcade menus */
 | 
						|
    if (challenge_name != "ACHIEVEMENTS"
 | 
						|
        && challenge_name != "STATS"
 | 
						|
        && challenge_name != "REPLAYS"
 | 
						|
        && challenge_name != "OPTIONS")
 | 
						|
    {
 | 
						|
        sb::extend(title_menu, {"start"});
 | 
						|
    }
 | 
						|
 | 
						|
    /* The challenge spinner is always available unless explicitly disabled */
 | 
						|
    if (button.at("challenge decrement").enabled())
 | 
						|
    {
 | 
						|
        sb::extend(title_menu, {"challenge decrement", "challenge increment"});
 | 
						|
    }
 | 
						|
 | 
						|
    /* If a play mode menu is active, play mode spinner buttons are available. */
 | 
						|
    if (challenge_name != "OPTIONS" &&
 | 
						|
        challenge_name != "ACHIEVEMENTS" &&
 | 
						|
        challenge_name != "STATS" &&
 | 
						|
        challenge_name != "REPLAYS")
 | 
						|
    {
 | 
						|
        if (button.at("level decrement").enabled())
 | 
						|
        {
 | 
						|
            sb::extend(title_menu, {"level decrement", "level increment"});
 | 
						|
        }
 | 
						|
        if (button.at("profile decrement").enabled())
 | 
						|
        {
 | 
						|
            sb::extend(title_menu, {"profile decrement", "profile increment"});
 | 
						|
        }
 | 
						|
        if (button.at("view decrement").enabled())
 | 
						|
        {
 | 
						|
            sb::extend(title_menu, {"view decrement", "view increment"});
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Sub-menu buttons are enabled in the options menu */
 | 
						|
    else if (challenge_name == "OPTIONS")
 | 
						|
    {
 | 
						|
        sb::extend(title_menu, {"fullscreen text", "bgm", "sfx", "exit"});
 | 
						|
    }
 | 
						|
 | 
						|
    /* Operator menu takes precedence and has its own UI handling, so ignore most other events. */
 | 
						|
    if (operator_menu_active)
 | 
						|
    {
 | 
						|
        const nlohmann::json& target_names = configuration()("operator", "order");
 | 
						|
 | 
						|
        /* Up and down edits the currently active textbox if applicable, and navigates UI otherwise. */
 | 
						|
        if (sb::Delegate::compare(event, "up") || sb::Delegate::compare(event, "down"))
 | 
						|
        {
 | 
						|
            /* Check if textbox is active */
 | 
						|
            bool found = false;
 | 
						|
            for (auto& [name, textbox] : operator_menu_textboxes)
 | 
						|
            {
 | 
						|
                if (textbox.editing())
 | 
						|
                {
 | 
						|
                    found = true;
 | 
						|
                    if (sb::Delegate::compare(event, "up"))
 | 
						|
                    {
 | 
						|
                        textbox.decrement_character();
 | 
						|
                    }
 | 
						|
                    else if (sb::Delegate::compare(event, "down"))
 | 
						|
                    {
 | 
						|
                        textbox.increment_character();
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            if (!found)
 | 
						|
            {
 | 
						|
                /* Navigation causes exit confirmation to be canceled. */
 | 
						|
                operator_menu_confirming = false;
 | 
						|
                load_operator_menu(true);
 | 
						|
 | 
						|
                if (sb::Delegate::compare(event, "up"))
 | 
						|
                {
 | 
						|
                    operator_menu_index_selected = std::max(0, operator_menu_index_selected - 1);
 | 
						|
                }
 | 
						|
                else if (sb::Delegate::compare(event, "down"))
 | 
						|
                {
 | 
						|
                    operator_menu_index_selected = std::min(
 | 
						|
                        int(target_names.size()) - 1, operator_menu_index_selected + 1);
 | 
						|
                } } }
 | 
						|
 | 
						|
        /* Select a menu item */
 | 
						|
        else if (sb::Delegate::compare(event, "any"))
 | 
						|
        {
 | 
						|
            const std::string& name { target_names[operator_menu_index_selected] };
 | 
						|
            if (operator_menu_buttons.count(name) > 0)
 | 
						|
            {
 | 
						|
                operator_menu_buttons.at(name).press();
 | 
						|
            }
 | 
						|
            else if (operator_menu_textboxes.count(name) > 0)
 | 
						|
            {
 | 
						|
                /* When pressing the textbox, check if the content was submitted and changed. If so, the status must be
 | 
						|
                 * set to edited. */
 | 
						|
                bool content_changed { operator_menu_textboxes.at(name).press() };
 | 
						|
                if (content_changed)
 | 
						|
                {
 | 
						|
                    operator_menu_edited = true;
 | 
						|
                    load_operator_menu(true);
 | 
						|
                } } } }
 | 
						|
 | 
						|
    /* Reset is only available outside of the operator menu */
 | 
						|
    else if (sb::Delegate::compare(event, "reset"))
 | 
						|
    {
 | 
						|
        zoom = 0.0f;
 | 
						|
        rotation = {0.0f, 0.0f};
 | 
						|
        load_level(0);
 | 
						|
        unpaused_timer.on();
 | 
						|
        run_timer.reset();
 | 
						|
        arcade_limit_warning = false;
 | 
						|
        confirming_new_quest = false;
 | 
						|
        confirming_new_arcade = false;
 | 
						|
 | 
						|
        /* In arcade-only mode, reset the level select to the first level */
 | 
						|
        if (configuration()("arcade", "arcade only"))
 | 
						|
        {
 | 
						|
            level_select_index = 1;
 | 
						|
            set_up_buttons();
 | 
						|
        } }
 | 
						|
 | 
						|
    /* Confirmation dialog takes control of input. In practice, it doesn't ever get enabled before the play button has
 | 
						|
     * been pressed or the splash is active, so this could be inside the next clause. But it's safe to call outside
 | 
						|
     * outside of the clause since it does need control whenever it's active. */
 | 
						|
    else if (confirming_new_quest || confirming_new_arcade)
 | 
						|
    {
 | 
						|
        /* Check press and collide states for the two confirmation dialog buttons */
 | 
						|
        bool press_confirm { false };
 | 
						|
        bool press_cancel { false };
 | 
						|
        bool collide_confirm { confirmation_confirm_button.collide(mouse_ndc, view, projection) };
 | 
						|
        bool collide_cancel { confirmation_cancel_button.collide(mouse_ndc, view, projection) };
 | 
						|
 | 
						|
        /* Check whether one of the buttons has been selected by the gamepad or mouse */
 | 
						|
        if (sb::Delegate::compare(event, "left"))
 | 
						|
        {
 | 
						|
            selected = confirmation_confirm_button;
 | 
						|
        }
 | 
						|
        else if (sb::Delegate::compare(event, "right"))
 | 
						|
        {
 | 
						|
            selected = confirmation_cancel_button;
 | 
						|
        }
 | 
						|
        else if (sb::Delegate::compare(event, "any"))
 | 
						|
        {
 | 
						|
            press_confirm = is_selected_button(confirmation_confirm_button);
 | 
						|
            press_cancel = is_selected_button(confirmation_confirm_button);
 | 
						|
        }
 | 
						|
        else if (event.type == SDL_MOUSEMOTION)
 | 
						|
        {
 | 
						|
            hovering = collide_confirm || collide_cancel;
 | 
						|
            if (collide_confirm)
 | 
						|
            {
 | 
						|
                selected = confirmation_confirm_button;
 | 
						|
            }
 | 
						|
            else if (collide_cancel)
 | 
						|
            {
 | 
						|
                selected = confirmation_cancel_button;
 | 
						|
            }
 | 
						|
        }
 | 
						|
        else if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
        {
 | 
						|
            press_confirm = collide_confirm;
 | 
						|
            press_cancel = collide_cancel;
 | 
						|
        }
 | 
						|
 | 
						|
        /* Press confirm or cancel if a press was detected. */
 | 
						|
        if (press_confirm)
 | 
						|
        {
 | 
						|
            selected.reset();
 | 
						|
            confirmation_confirm_button.press();
 | 
						|
        }
 | 
						|
        else if (press_cancel)
 | 
						|
        {
 | 
						|
            selected.reset();
 | 
						|
            confirmation_cancel_button.press();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Ignore most events when play button or splash screen is active */
 | 
						|
    else if ((!use_play_button || button.at("play").pressed()) && !splash_animation.playing())
 | 
						|
    {
 | 
						|
        /* Title screen and pause menu navigation is disabled when arcade-only or demo modes are active */
 | 
						|
        bool menu_active = !configuration()("arcade", "arcade only") && !configuration()("demo", "active");
 | 
						|
 | 
						|
        /* Track whether a button has been pressed with this event */
 | 
						|
        bool button_pressed = false;
 | 
						|
 | 
						|
        /* Store the value of the selected button, so it can be checked at the end to see if the selected button has
 | 
						|
         * changed. */
 | 
						|
        std::optional<std::reference_wrapper<sb::Pad<>>> previously_selected = selected;
 | 
						|
 | 
						|
        /* Custom keys for the title screen */
 | 
						|
        if (level_index == 0)
 | 
						|
        {
 | 
						|
            /* Prevent navigating into menus in demo and arcade-only modes */
 | 
						|
            if (menu_active)
 | 
						|
            {
 | 
						|
                if (sb::Delegate::compare(event, {"up", "right", "down", "left"}))
 | 
						|
                {
 | 
						|
                    if (selected.has_value())
 | 
						|
                    {
 | 
						|
                        /* Build a custom pool of buttons to choose from */
 | 
						|
                        std::vector<std::reference_wrapper<sb::Pad<>>> pool;
 | 
						|
                        for (const std::string& name : title_menu)
 | 
						|
                        {
 | 
						|
                            pool.push_back(std::ref(button.at(name)));
 | 
						|
                        }
 | 
						|
 | 
						|
                        if (challenge_name == "REPLAYS")
 | 
						|
                        {
 | 
						|
                            for (sb::Pad<>& button : replay_buttons)
 | 
						|
                            {
 | 
						|
                                pool.push_back(std::ref(button));
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                        selected = nearest_button(selected.value(), sb::Delegate::event_command(event), pool);
 | 
						|
                    }
 | 
						|
                    else if (sb::contains(title_menu, "start"))
 | 
						|
                    {
 | 
						|
                        selected = button.at("start");
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        selected = button.at("challenge decrement");
 | 
						|
                    } } }
 | 
						|
 | 
						|
            /* Execute menu action */
 | 
						|
            if (sb::Delegate::compare(event, "any"))
 | 
						|
            {
 | 
						|
                button_pressed = true;
 | 
						|
                if (!selected.has_value())
 | 
						|
                {
 | 
						|
                    if (sb::contains(title_menu, "start"))
 | 
						|
                    {
 | 
						|
                        button.at("start").press();
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    /* This is temporary until Pad::press is removed in favor of Pad::click */
 | 
						|
                    try
 | 
						|
                    {
 | 
						|
                        selected->get().press();
 | 
						|
                    }
 | 
						|
                    catch (const std::bad_function_call& error)
 | 
						|
                    {
 | 
						|
                        selected->get().click();
 | 
						|
                    } } } }
 | 
						|
 | 
						|
        /* Custom keys for name entry. */
 | 
						|
        else if (static_cast<std::size_t>(level_index) == configuration()("levels").size() - 1 && arcade() &&
 | 
						|
                 configuration()("display", "name entry enabled"))
 | 
						|
        {
 | 
						|
            if (sb::Delegate::compare(event, "up"))
 | 
						|
            {
 | 
						|
                button.at("name " + std::to_string(name_entry_index + 1) + " increment").press();
 | 
						|
            }
 | 
						|
            else if (sb::Delegate::compare(event, "right"))
 | 
						|
            {
 | 
						|
                if (++name_entry_index > 2) name_entry_index = 0;
 | 
						|
            }
 | 
						|
            else if (sb::Delegate::compare(event, "down"))
 | 
						|
            {
 | 
						|
                button.at("name " + std::to_string(name_entry_index + 1) + " decrement").press();
 | 
						|
            }
 | 
						|
            else if (sb::Delegate::compare(event, "left"))
 | 
						|
            {
 | 
						|
                if (--name_entry_index < 0) name_entry_index = 2;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Custom keys for pause menu */
 | 
						|
        else if (!unpaused_timer)
 | 
						|
        {
 | 
						|
            if (sb::Delegate::compare(event, "up") || sb::Delegate::compare(event, "down"))
 | 
						|
            {
 | 
						|
                if (selected.has_value())
 | 
						|
                {
 | 
						|
                    selected = nearest_button(
 | 
						|
                        selected.value(),
 | 
						|
                        sb::Delegate::event_command(event),
 | 
						|
                        {"resume", "reset", "fullscreen text", "bgm", "sfx"});
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    selected = button.at("resume");
 | 
						|
                }
 | 
						|
            }
 | 
						|
            else if (selected.has_value() && sb::Delegate::compare(event, "any"))
 | 
						|
            {
 | 
						|
                selected->get().press();
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Custom keys for replay end screen. */
 | 
						|
        else if (replay_playback && replay_playback->ended() && unpaused_timer)
 | 
						|
        {
 | 
						|
            sb::Pad<>& restart = button.at("replay restart");
 | 
						|
            sb::Pad<>& exit = button.at("replay exit");
 | 
						|
            if (sb::Delegate::compare(event, "left"))
 | 
						|
            {
 | 
						|
                selected = restart.box().cx() <= exit.box().cx() ? restart : exit;
 | 
						|
            }
 | 
						|
            else if (sb::Delegate::compare(event, "right"))
 | 
						|
            {
 | 
						|
                selected = restart.box().cx() > exit.box().cx() ? restart : exit;
 | 
						|
            }
 | 
						|
            else if (selected.has_value() && sb::Delegate::compare(event, "any"))
 | 
						|
            {
 | 
						|
                selected->get().click();
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Perspective and view modifications */
 | 
						|
        if (event.type == SDL_MOUSEWHEEL && shift_pressed)
 | 
						|
        {
 | 
						|
            /* Edit zoom level with mouse wheel, which will modify the FOV. */
 | 
						|
            zoom = std::clamp(zoom - event.wheel.preciseY * glm::radians(2.0f),
 | 
						|
                              glm::radians(-30.0f),
 | 
						|
                              glm::radians(30.0f));
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check mouse-up and button-up for ending character acceleration */
 | 
						|
        else if (sb::Delegate::compare_cancel(event, "any") ||
 | 
						|
                 (event.type == SDL_MOUSEBUTTONUP && event.button.button == SDL_BUTTON_LEFT))
 | 
						|
        {
 | 
						|
            character.accelerating = false;
 | 
						|
        }
 | 
						|
 | 
						|
        /* Check mouse clicks and button presses */
 | 
						|
        else if (event.type == SDL_MOUSEMOTION ||
 | 
						|
                 event.type == SDL_MOUSEBUTTONDOWN ||
 | 
						|
                 sb::Delegate::compare(event, "any"))
 | 
						|
        {
 | 
						|
            /* Collide with start button, spinners, and options sub-menu only on title screen */
 | 
						|
            if (level_index == 0)
 | 
						|
            {
 | 
						|
                /* Check replay sub-menu */
 | 
						|
                if (challenge_name == "REPLAYS" && !configuration()("arcade", "arcade only"))
 | 
						|
                {
 | 
						|
                    for (std::size_t button_index = 0; button_index < replay_buttons.size(); button_index++)
 | 
						|
                    {
 | 
						|
                        sb::Pad<>& button { replay_buttons[button_index] };
 | 
						|
 | 
						|
                        /* Only use the button if it has a replay that can be launched. */
 | 
						|
                        if (button.enabled() && button.collide(mouse_ndc, view, projection))
 | 
						|
                        {
 | 
						|
                            selected = button;
 | 
						|
                            if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
                            {
 | 
						|
                                button_pressed = true;
 | 
						|
                                button.click();
 | 
						|
                            }
 | 
						|
                            else
 | 
						|
                            {
 | 
						|
                                hovering = true;
 | 
						|
                            } } } }
 | 
						|
 | 
						|
                for (const std::string& name : title_menu)
 | 
						|
                {
 | 
						|
                    if (!configuration()("arcade", "arcade only") || name == "start")
 | 
						|
                    {
 | 
						|
                        if (button.at(name).enabled() && button.at(name).collide(mouse_ndc, view, projection))
 | 
						|
                        {
 | 
						|
                            selected = button.at(name);
 | 
						|
                            hovering = true;
 | 
						|
                            if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
                            {
 | 
						|
                                button_pressed = true;
 | 
						|
                                button.at(name).press();
 | 
						|
 | 
						|
                                /* Cancel hover on the start button because the button will be removed from the screen
 | 
						|
                                 * after the press. */
 | 
						|
                                if (name == "start") hovering = false;
 | 
						|
                            } } } } }
 | 
						|
 | 
						|
            /* Collide with pause button only during levels */
 | 
						|
            if (level_index > 0 && unpaused_timer && button.at("pause").collide(mouse_ndc, view, projection))
 | 
						|
            {
 | 
						|
                if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
                {
 | 
						|
                    button.at("pause").press();
 | 
						|
                    button_pressed = true;
 | 
						|
                }
 | 
						|
                else hovering = true;
 | 
						|
            }
 | 
						|
 | 
						|
            /* Check pause menu buttons */
 | 
						|
            else if (level_index > 0 && !unpaused_timer)
 | 
						|
            {
 | 
						|
                std::vector<std::string> names {"resume", "reset", "bgm", "sfx", "fullscreen text"};
 | 
						|
                for (const std::string& button_name : names)
 | 
						|
                {
 | 
						|
                    if (button.at(button_name).enabled() && button.at(button_name).collide(mouse_ndc, view, projection))
 | 
						|
                    {
 | 
						|
                        selected = button.at(button_name);
 | 
						|
                        if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
                        {
 | 
						|
                            button.at(button_name).press();
 | 
						|
                            button_pressed = true;
 | 
						|
                        }
 | 
						|
                        else hovering = true;
 | 
						|
                    } } }
 | 
						|
 | 
						|
            /* Check replay end screen */
 | 
						|
            else if (replay_playback && replay_playback->ended())
 | 
						|
            {
 | 
						|
                for (const std::string& name : {"replay restart", "replay exit"})
 | 
						|
                {
 | 
						|
                    if (button.at(name).enabled() && button.at(name).collide(mouse_ndc, view, projection))
 | 
						|
                    {
 | 
						|
                        selected = button.at(name);
 | 
						|
                        if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
                        {
 | 
						|
                            button.at(name).click();
 | 
						|
                            button_pressed = true;
 | 
						|
                        }
 | 
						|
                        else
 | 
						|
                        {
 | 
						|
                            hovering = true;
 | 
						|
                        } } } }
 | 
						|
 | 
						|
            /* Collide with name entry in arcade mode on end screen */
 | 
						|
            else if (static_cast<std::size_t>(level_index) == configuration()("levels").size() - 1 &&
 | 
						|
                     arcade() && configuration()("display", "name entry enabled"))
 | 
						|
            {
 | 
						|
                for (const std::string& button_name : {
 | 
						|
                        std::string("name 1"), std::string("name 2"), std::string("name 3"),
 | 
						|
                        "name " + std::to_string(name_entry_index + 1) + " increment",
 | 
						|
                        "name " + std::to_string(name_entry_index + 1) + " decrement"})
 | 
						|
                {
 | 
						|
                    if (button.at(button_name).collide(mouse_ndc, view, projection))
 | 
						|
                    {
 | 
						|
                        if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
                        {
 | 
						|
                            button.at(button_name).press();
 | 
						|
                            button_pressed = true;
 | 
						|
                        }
 | 
						|
                        else hovering = true;
 | 
						|
                    } } }
 | 
						|
 | 
						|
            /* Rotate scene */
 | 
						|
            if (event.type == SDL_MOUSEMOTION && left_mouse_pressed && shift_pressed)
 | 
						|
            {
 | 
						|
                rotation += glm::vec2{event.motion.xrel, event.motion.yrel} * glm::half_pi<float>() * 0.005f;
 | 
						|
            }
 | 
						|
 | 
						|
            /* Start character acceleration */
 | 
						|
            bool acceleration_pressed = (
 | 
						|
                (event.type == SDL_MOUSEBUTTONDOWN && event.button.button == SDL_BUTTON_LEFT) ||
 | 
						|
                sb::Delegate::compare(event, "any"));
 | 
						|
            if (!shift_pressed && !button_pressed && acceleration_pressed && unpaused_timer && !replay_playback)
 | 
						|
            {
 | 
						|
                character.accelerating = true;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        else if (sb::Delegate::compare(event, "fps"))
 | 
						|
        {
 | 
						|
            configuration()["display"]["fps"] = !configuration()["display"]["fps"];
 | 
						|
        }
 | 
						|
 | 
						|
        else if (sb::Delegate::compare(event, "skip forward"))
 | 
						|
        {
 | 
						|
            load_level(level_index + 1);
 | 
						|
        }
 | 
						|
 | 
						|
        else if (sb::Delegate::compare(event, "skip backward"))
 | 
						|
        {
 | 
						|
            load_level(level_index - 1);
 | 
						|
        }
 | 
						|
 | 
						|
        else if (sb::Delegate::compare(event, "pause") && level_index > 0 &&
 | 
						|
                 static_cast<std::size_t>(level_index) <= configuration()("levels").size() - 2)
 | 
						|
        {
 | 
						|
            if (!unpaused_timer)
 | 
						|
            {
 | 
						|
                sb::Delegate::post("resume", false);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                /* Pause */
 | 
						|
                unpaused_timer.off();
 | 
						|
                run_timer.off();
 | 
						|
 | 
						|
                /* User interface */
 | 
						|
                selected = button.at("resume");
 | 
						|
                set_up_buttons();
 | 
						|
 | 
						|
                /* Cancel sfx */
 | 
						|
                audio.at("walk").stop();
 | 
						|
                audio.at("reverse").stop();
 | 
						|
 | 
						|
                /* Transition between main theme and menu theme */
 | 
						|
                if (bgm.playing())
 | 
						|
                {
 | 
						|
                    bgm.pause();
 | 
						|
                }
 | 
						|
                if (audio.at("menu").paused())
 | 
						|
                {
 | 
						|
                    audio.at("menu").resume();
 | 
						|
                }
 | 
						|
                else if (audio.at("menu").fading() || !audio.at("menu").playing())
 | 
						|
                {
 | 
						|
                    audio.at("menu").play();
 | 
						|
                }
 | 
						|
 | 
						|
                /* Offset the rotation animation so that it is at neutral, so the replay stage end rotation effect
 | 
						|
                 * starts rotating from the beginning of the rotation. */
 | 
						|
                if (!replay_playback)
 | 
						|
                {
 | 
						|
                    auto_rotation_time_offset = -(SDL_GetTicks() / 1000.0f);
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        else if (sb::Delegate::compare(event, "resume") && !unpaused_timer &&
 | 
						|
                 level_index > 0 && static_cast<std::size_t>(level_index) <= configuration()("levels").size() - 2)
 | 
						|
        {
 | 
						|
            selected.reset();
 | 
						|
 | 
						|
            /* Un-pause */
 | 
						|
            unpaused_timer.on();
 | 
						|
 | 
						|
            if (!replay_playback || !replay_playback->ended())
 | 
						|
            {
 | 
						|
                run_timer.on();
 | 
						|
            }
 | 
						|
 | 
						|
            /* Transition between menu theme and main theme */
 | 
						|
            if (audio.at("menu").playing())
 | 
						|
            {
 | 
						|
                audio.at("menu").pause();
 | 
						|
            }
 | 
						|
            if (bgm.paused())
 | 
						|
            {
 | 
						|
                bgm.resume();
 | 
						|
            }
 | 
						|
            else if (bgm.fading() || !bgm.playing())
 | 
						|
            {
 | 
						|
                bgm.play();
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        else if (sb::Delegate::compare(event, "pause for ads"))
 | 
						|
        {
 | 
						|
            /* Store current volume to be restored when returning from ads by looking at the state of the button. */
 | 
						|
            if (button.at("volume").pressed())
 | 
						|
            {
 | 
						|
                pre_ad_volume = 1.0f;
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                pre_ad_volume = 0.0f;
 | 
						|
            }
 | 
						|
            std::ostringstream message;
 | 
						|
            message << "Pre-ad volume registered as " << pre_ad_volume.value();
 | 
						|
            sb::Log::log(message);
 | 
						|
 | 
						|
            /* Mute without changing the state of the button to avoid losing the original state if this event is fired
 | 
						|
             * twice in a row. */
 | 
						|
            if (Mix_QuerySpec(nullptr, nullptr, nullptr) != 0)
 | 
						|
            {
 | 
						|
                Mix_Volume(-1, 0);
 | 
						|
                Mix_VolumeMusic(0);
 | 
						|
            }
 | 
						|
 | 
						|
            if (level_index > 0 && static_cast<std::size_t>(level_index) <= configuration()("levels").size() - 2)
 | 
						|
            {
 | 
						|
                /* Pause game */
 | 
						|
                unpaused_timer.off();
 | 
						|
                run_timer.off();
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        else if (sb::Delegate::compare(event, "unpause for ads"))
 | 
						|
        {
 | 
						|
            /* Restore volume to the volume of the mixer before the ads started */
 | 
						|
            std::ostringstream message;
 | 
						|
            if (pre_ad_volume.has_value() && Mix_QuerySpec(nullptr, nullptr, nullptr) != 0)
 | 
						|
            {
 | 
						|
                int volume_int { sb::audio::convert_volume(pre_ad_volume.value()) };
 | 
						|
                Mix_Volume(-1, volume_int);
 | 
						|
                Mix_VolumeMusic(volume_int);
 | 
						|
                message << "Restoring volume to " << pre_ad_volume.value();
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                message << "Not restoring volume because pre-ad value was not registered";
 | 
						|
            }
 | 
						|
            sb::Log::log(message);
 | 
						|
 | 
						|
            if (level_index > 0 && static_cast<std::size_t>(level_index) <= configuration()("levels").size() - 2)
 | 
						|
            {
 | 
						|
                /* Unpause game */
 | 
						|
                unpaused_timer.on();
 | 
						|
                run_timer.on();
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
#if !defined(__MINGW32__) && !defined(__MACOS__)
 | 
						|
        /* Taken from mallinfo man page, log a profile of the memory when the command is sent. */
 | 
						|
        else if (sb::Delegate::compare(event, "memory"))
 | 
						|
        {
 | 
						|
            /* Struct with quantified memory allocation information. */
 | 
						|
#if !defined(EMSCRIPTEN) && !defined(__UBUNTU18__)
 | 
						|
            struct mallinfo2 malloc_info = mallinfo2();
 | 
						|
#else
 | 
						|
            struct mallinfo malloc_info = mallinfo();
 | 
						|
#endif
 | 
						|
 | 
						|
            /* Create a map from the struct's member variables. */
 | 
						|
            std::map<std::string, int> malloc_map = {
 | 
						|
                {"Total non-mmapped bytes (arena):", malloc_info.arena},
 | 
						|
                {"# of free chunks (ordblks):", malloc_info.ordblks},
 | 
						|
                {"# of free fastbin blocks (smblks):", malloc_info.smblks},
 | 
						|
                {"# of mapped regions (hblks):", malloc_info.hblks},
 | 
						|
                {"Bytes in mapped regions (hblkhd):", malloc_info.hblkhd},
 | 
						|
                {"Max. total allocated space (usmblks):", malloc_info.usmblks},
 | 
						|
                {"Free bytes held in fastbins (fsmblks):", malloc_info.fsmblks},
 | 
						|
                {"Total allocated space (uordblks):", malloc_info.uordblks},
 | 
						|
                {"Total free space (fordblks):", malloc_info.fordblks},
 | 
						|
                {"Topmost releasable block (keepcost):", malloc_info.keepcost},
 | 
						|
            };
 | 
						|
 | 
						|
            /* Loop through the map, and print each value. */
 | 
						|
            std::ostringstream message;
 | 
						|
            int first_column = 40, second_column = 12, count = 0;
 | 
						|
            for (std::pair<std::string, int> malloc_info_entry : malloc_map)
 | 
						|
            {
 | 
						|
                message << std::setw(first_column) << malloc_info_entry.first << std::setw(second_column) << std::setprecision(2)
 | 
						|
                        << std::fixed << malloc_info_entry.second / 1'000'000.0 << " MB";
 | 
						|
                if ((++count % 2) == 0)
 | 
						|
                {
 | 
						|
                    message << std::endl;
 | 
						|
                }
 | 
						|
            }
 | 
						|
            message << "---" << std::endl;
 | 
						|
            sb::Log::log(message);
 | 
						|
        }
 | 
						|
#endif
 | 
						|
 | 
						|
        /* Print the coordinates of the cake sprite in all coordinate spaces */
 | 
						|
        else if (sb::Delegate::compare(event, "coords"))
 | 
						|
        {
 | 
						|
            std::ostringstream message;
 | 
						|
            glm::vec2 translation = sb::math::wrap_point(
 | 
						|
                character.position(), {-curve().aspect, -1.0f, 0.0f}, {curve().aspect, 1.0f, 1.0f});
 | 
						|
            message << std::fixed << std::setprecision(2) << "Character coords: unwrapped " << character.position() <<
 | 
						|
                ", wrapped " << translation << ", clip " << sb::math::world_to_clip(translation, projection * view) <<
 | 
						|
                ", ndc " << sb::math::world_to_ndc(translation, projection * view) << ", window " <<
 | 
						|
                sb::math::world_to_viewport(translation, window_box().size(), projection * view);
 | 
						|
            sb::Log::log(message);
 | 
						|
        }
 | 
						|
 | 
						|
        /* If there is a selected button and it has changed from the previous value, play a sound effect. */
 | 
						|
        if ((!previously_selected && selected.has_value()) ||
 | 
						|
            (selected && previously_selected && !is_selected_button(previously_selected.value())))
 | 
						|
        {
 | 
						|
            audio.at(configuration()("button", "default select audio")).play();
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else if (use_play_button && !button.at("play").pressed())
 | 
						|
    {
 | 
						|
        /* Collide with play button */
 | 
						|
        if ((event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEMOTION) &&
 | 
						|
            button.at("play").collide(mouse_ndc, view, projection))
 | 
						|
        {
 | 
						|
            if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
            {
 | 
						|
                button.at("play").press();
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                hovering = true;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Any keyboard input causes the play button to press */
 | 
						|
        else if (sb::Delegate::compare(event, "any"))
 | 
						|
        {
 | 
						|
            button.at("play").press();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Reconfig and window resize should be handled regardless of the context */
 | 
						|
    if (sb::Delegate::compare(event, "reconfig"))
 | 
						|
    {
 | 
						|
        reconfig();
 | 
						|
    }
 | 
						|
    else if (sb::Delegate::compare(event, "window resize"))
 | 
						|
    {
 | 
						|
        set_up_buttons();
 | 
						|
        set_up_hud();
 | 
						|
 | 
						|
        /* Pass the resolution to the current shader program. If this uniform doesn't exist, this will fail but only
 | 
						|
         * with a log message. */
 | 
						|
        glm::vec2 resolution { display.window_box().size() };
 | 
						|
        glUniform2f(uniform.at("resolution"), resolution.x, resolution.y);
 | 
						|
        sb::Log::gl_errors("setting resolution uniform after window resize");
 | 
						|
    }
 | 
						|
 | 
						|
    /* Always collide with volume button and fullscreen if enabled */
 | 
						|
    for (const std::string name : {"volume", "fullscreen"})
 | 
						|
    {
 | 
						|
        if (name != "fullscreen" || configuration()("display", "fullscreen enabled"))
 | 
						|
        {
 | 
						|
            if ((event.type == SDL_MOUSEBUTTONDOWN ||
 | 
						|
                 event.type == SDL_MOUSEMOTION) && button.at(name).collide(mouse_ndc, view, projection))
 | 
						|
            {
 | 
						|
                if (event.type == SDL_MOUSEBUTTONDOWN)
 | 
						|
                {
 | 
						|
                    button.at(name).press();
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    hovering = true;
 | 
						|
                }
 | 
						|
                break;
 | 
						|
            } } }
 | 
						|
 | 
						|
    /* Set the cursor image appropriately */
 | 
						|
    if (hovering && SDL_GetCursor() == SDL_GetDefaultCursor())
 | 
						|
    {
 | 
						|
        SDL_SetCursor(poke.get());
 | 
						|
    }
 | 
						|
    else if (!hovering && SDL_GetCursor() == poke.get())
 | 
						|
    {
 | 
						|
        SDL_SetCursor(SDL_GetDefaultCursor());
 | 
						|
    }
 | 
						|
 | 
						|
    /* Display mouse or gamepad/keyboard UI. The UI changes state only if input was detected, and the state chosen is based on
 | 
						|
     * whether the detected input is mouse or joypad/keyboard input. */
 | 
						|
    if (mouse_input_registered || joy_or_key_input_registered)
 | 
						|
    {
 | 
						|
        SDL_ShowCursor(mouse_input_registered);
 | 
						|
        button.at("fullscreen").visible(mouse_input_registered);
 | 
						|
        button.at("volume").visible(mouse_input_registered);
 | 
						|
        button.at("pause").visible(mouse_input_registered);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::paused() const
 | 
						|
{
 | 
						|
    return !unpaused_timer;
 | 
						|
}
 | 
						|
 | 
						|
sb::Pad<>& Cakefoot::nearest_button(
 | 
						|
    sb::Pad<>& subject,
 | 
						|
    const std::string& direction,
 | 
						|
    const std::vector<std::reference_wrapper<sb::Pad<>>>& pool)
 | 
						|
{
 | 
						|
    /* Track the nearest */
 | 
						|
    std::optional<std::reference_wrapper<sb::Pad<>>> nearest;
 | 
						|
 | 
						|
    /* Discard all pads that are not in the indicated direction. Replace currently closest pad if the pad is closer
 | 
						|
     * along the axis of the given direction. */
 | 
						|
    float closest_distance = 0.0f;
 | 
						|
    for (sb::Pad<>& pad : pool)
 | 
						|
    {
 | 
						|
        /* Don't search against self or disabled buttons */
 | 
						|
        if (pad.enabled() && !is_selected_button(pad, subject))
 | 
						|
        {
 | 
						|
            /* Check if button is within the given direction and record distance along the given axis */
 | 
						|
            bool in_pool = false;
 | 
						|
            float pad_distance = 0.0f;
 | 
						|
            if (direction == "up")
 | 
						|
            {
 | 
						|
                in_pool = pad.box().bottom() >= subject.box().top();
 | 
						|
                pad_distance = glm::distance(pad.box().bottom(), subject.box().top());
 | 
						|
            }
 | 
						|
            else if (direction == "right")
 | 
						|
            {
 | 
						|
                in_pool = pad.box().left() >= subject.box().right();
 | 
						|
                pad_distance = glm::distance(pad.box().left(), subject.box().right());
 | 
						|
            }
 | 
						|
            else if (direction == "down")
 | 
						|
            {
 | 
						|
                in_pool = pad.box().top() <= subject.box().bottom();
 | 
						|
                pad_distance = glm::distance(pad.box().top(), subject.box().bottom());
 | 
						|
            }
 | 
						|
            else if (direction == "left")
 | 
						|
            {
 | 
						|
                in_pool = pad.box().right() <= subject.box().left();
 | 
						|
                pad_distance = glm::distance(pad.box().right(), subject.box().left());
 | 
						|
            }
 | 
						|
 | 
						|
            /* If the pad is within given direction and closer on the given axis, replace the current nearest. If the
 | 
						|
             * pad is the same distance as the nearest along the given axis, only replace the current nearest if the
 | 
						|
             * pad's center is closer than the current nearest's center. */
 | 
						|
            if (in_pool && (!nearest.has_value() ||  pad_distance <= closest_distance))
 | 
						|
            {
 | 
						|
                if (pad_distance != closest_distance ||
 | 
						|
                    glm::distance(pad.box().center(), subject.box().center()) <
 | 
						|
                    glm::distance(nearest->get().box().center(), subject.box().center()))
 | 
						|
                {
 | 
						|
                    nearest = pad;
 | 
						|
                    closest_distance = pad_distance;
 | 
						|
                }
 | 
						|
            } } }
 | 
						|
 | 
						|
    if (nearest.has_value())
 | 
						|
    {
 | 
						|
        return nearest.value();
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        return subject;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
sb::Pad<>& Cakefoot::nearest_button(
 | 
						|
    sb::Pad<>& subject,
 | 
						|
    const std::string& direction,
 | 
						|
    const std::vector<std::string>& subset)
 | 
						|
{
 | 
						|
    std::vector<std::reference_wrapper<sb::Pad<>>> pool;
 | 
						|
 | 
						|
    /* Add named buttons to pool */
 | 
						|
    for (auto& [name, pad] : button)
 | 
						|
    {
 | 
						|
        if (subset.empty() || sb::contains(subset, name))
 | 
						|
        {
 | 
						|
            pool.push_back(std::ref(pad));
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Add replay and confirmation buttons to pool if not searching for a named button */
 | 
						|
    if (subset.empty())
 | 
						|
    {
 | 
						|
        for (sb::Pad<>& pad : replay_buttons)
 | 
						|
        {
 | 
						|
            pool.push_back(std::ref(pad));
 | 
						|
        }
 | 
						|
        pool.push_back(std::ref(confirmation_confirm_button));
 | 
						|
        pool.push_back(std::ref(confirmation_cancel_button));
 | 
						|
    }
 | 
						|
    return nearest_button(subject, direction, pool);
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::is_selected_button(const sb::Pad<>& subject, const sb::Pad<>& other) const
 | 
						|
{
 | 
						|
    return std::addressof(subject) == std::addressof(other);
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::is_selected_button(const sb::Pad<>& subject) const
 | 
						|
{
 | 
						|
    return selected.has_value() && is_selected_button(subject, selected->get());
 | 
						|
}
 | 
						|
 | 
						|
bool Cakefoot::is_selected_button(const std::string& name) const
 | 
						|
{
 | 
						|
    return selected.has_value() && button.count(name) > 0 && is_selected_button(button.at(name));
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::reconfig()
 | 
						|
{
 | 
						|
    /* Reload fonts */
 | 
						|
    fonts = std::map<std::string, std::shared_ptr<TTF_Font>> {
 | 
						|
        {"medium", font(configuration()("font", "medium", "path").get<std::string>(),
 | 
						|
                        configuration()("font", "medium", "size"))},
 | 
						|
        {"large", font(configuration()("font", "large", "path").get<std::string>(),
 | 
						|
                       configuration()("font", "large", "size"))},
 | 
						|
        {"glyph", font(configuration()("font", "glyph", "path").get<std::string>(),
 | 
						|
                       configuration()("font", "glyph", "size"))},
 | 
						|
        {"glyph large", font(configuration()("font", "glyph large", "path").get<std::string>(),
 | 
						|
                             configuration()("font", "glyph large", "size"))},
 | 
						|
        {"narrow medium", font(configuration()("font", "narrow medium", "path").get<std::string>(),
 | 
						|
                               configuration()("font", "narrow medium", "size"))},
 | 
						|
        {"narrow progress menu", font(configuration()("font", "narrow progress menu", "path").get<std::string>(),
 | 
						|
                                      configuration()("font", "narrow progress menu", "size"), std::nullopt,
 | 
						|
                                      TTF_WRAPPED_ALIGN_CENTER)}
 | 
						|
    };
 | 
						|
 | 
						|
    /* Reload labels */
 | 
						|
    label = std::map<std::string, sb::Text> {
 | 
						|
        {"fps", sb::Text(font())},
 | 
						|
        {"clock", sb::Text(font())},
 | 
						|
        {"level", sb::Text(font())},
 | 
						|
        {"level select", sb::Text(fonts.at("narrow medium"))},
 | 
						|
        {"profile", sb::Text(fonts.at("narrow medium"))},
 | 
						|
        {"challenge", sb::Text(fonts.at("narrow medium"))},
 | 
						|
        {"view", sb::Text(fonts.at("narrow medium"))},
 | 
						|
        {"game over", sb::Text(font())},
 | 
						|
        {"arcade rank", sb::Text(fonts.at("large"))},
 | 
						|
        {"arcade distance", sb::Text(fonts.at("large"))},
 | 
						|
        {"quest best", sb::Text(fonts.at("glyph"))},
 | 
						|
        {"idle warning", sb::Text(font())},
 | 
						|
        {"version", sb::Text(fonts.at("narrow medium"))}
 | 
						|
    };
 | 
						|
 | 
						|
    /* Reload stats and achievements */
 | 
						|
    achievements = sb::progress::Achievements {configuration()};
 | 
						|
    stats = sb::progress::Stats {configuration()};
 | 
						|
 | 
						|
    /* Reload audio. Save audio status and re-enable if necessary. */
 | 
						|
    bool menu_playing = audio.at("menu").playing();
 | 
						|
    bool bgm_playing = bgm.playing();
 | 
						|
    load_audio();
 | 
						|
    if (menu_playing)
 | 
						|
    {
 | 
						|
        audio.at("menu").play();
 | 
						|
    }
 | 
						|
    else if (bgm_playing)
 | 
						|
    {
 | 
						|
        bgm.play();
 | 
						|
    }
 | 
						|
 | 
						|
    /* Graphics */
 | 
						|
    load_shaders();
 | 
						|
    load_curves();
 | 
						|
    load_vbo();
 | 
						|
    load_level(level_index);
 | 
						|
    character.box_size(configuration()("character", "hitbox").get<float>());
 | 
						|
    rotating_hue.hsv(
 | 
						|
        0.0f, configuration()("display", "highlight saturation"), configuration()("display", "highlight value"));
 | 
						|
    _candle_lit = sb::Sprite {
 | 
						|
        configuration()("candle", "lit", "frames").begin(),
 | 
						|
        configuration()("candle", "lit", "frames").end(),
 | 
						|
        configuration()("candle", "scale"),
 | 
						|
        GL_LINEAR
 | 
						|
    };
 | 
						|
    _candle_unlit = sb::Sprite {
 | 
						|
        configuration()("candle", "unlit", "frames").begin(),
 | 
						|
        configuration()("candle", "unlit", "frames").end(),
 | 
						|
        configuration()("candle", "scale"),
 | 
						|
        GL_LINEAR
 | 
						|
    };
 | 
						|
 | 
						|
    /* Reload user interface elements */
 | 
						|
    set_up_buttons();
 | 
						|
    set_up_hud();
 | 
						|
    load_achievements_menu();
 | 
						|
    load_stats_menu();
 | 
						|
    load_replays_menu();
 | 
						|
    load_operator_menu();
 | 
						|
    load_confirmation_alert();
 | 
						|
 | 
						|
    /* Reset animation timings */
 | 
						|
    shift_hue_animation.frame_length(configuration()("display", "hue shift frequency"));
 | 
						|
    swap_title_shader_animation.frame_length(configuration()("shaders", "title swap frequency"));
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::run(std::function<void(float)> draw, std::optional<std::function<void(float)>> update)
 | 
						|
{
 | 
						|
    /* Start timers precisely when the game update loop starts */
 | 
						|
    on_timer.on();
 | 
						|
    unpaused_timer.on();
 | 
						|
 | 
						|
    /* Enable auto refresh */
 | 
						|
#if defined(__LINUX__)
 | 
						|
    configuration().enable_auto_refresh(levels_file_path);
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Start the update loop */
 | 
						|
    sb::Game::run(draw, update);
 | 
						|
}
 | 
						|
 | 
						|
int Cakefoot::max_challenge() const
 | 
						|
{
 | 
						|
    return configuration()("challenge").size() - 1;
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::draw(float timestamp)
 | 
						|
{
 | 
						|
    sb::Log::gl_errors("at beginning of update");
 | 
						|
 | 
						|
    /* Update time in seconds the game has been running for, pass to the shader. */
 | 
						|
    on_timer.update(timestamp);
 | 
						|
    glUniform1f(uniform.at("time"), on_timer.elapsed());
 | 
						|
    stat_progress.increment_stat(stats["STAT_POWER_ON_TIME"], on_timer.frame(), achievements, stats);
 | 
						|
 | 
						|
    /* Keep animation time updated */
 | 
						|
    game_over_animation.update(timestamp);
 | 
						|
    submit_score_animation.update(timestamp);
 | 
						|
    shift_hue_animation.update(timestamp);
 | 
						|
    flash_animation.update(timestamp);
 | 
						|
    blink_animation.update(timestamp);
 | 
						|
    cooldown_animation.update(timestamp);
 | 
						|
    splash_animation.update(timestamp);
 | 
						|
    warning_animation.update(timestamp);
 | 
						|
    save_stats_animation.update(timestamp);
 | 
						|
    sync_stats_animation.update(timestamp);
 | 
						|
    sync_session_animation.update(timestamp);
 | 
						|
    swap_title_shader_animation.update(timestamp);
 | 
						|
    _candle_lit.update(timestamp);
 | 
						|
    _candle_unlit.update(timestamp);
 | 
						|
    play_button_bg.update(timestamp);
 | 
						|
    demo_timer.update(timestamp);
 | 
						|
 | 
						|
#if defined(__LINUX__)
 | 
						|
    update_game_version_animation.update(timestamp);
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Update HTTP queue */
 | 
						|
#if defined(HTTP_ENABLED)
 | 
						|
    http.update(timestamp);
 | 
						|
#endif
 | 
						|
 | 
						|
    /* Update title screen animation */
 | 
						|
    float effect_zoom {0.0f};
 | 
						|
    glm::vec2 effect_rotation {0.0f, 0.0f};
 | 
						|
    if (level_index == 0 && title_name_animation.update(timestamp))
 | 
						|
    {
 | 
						|
        const nlohmann::json& effect = configuration()("attract");
 | 
						|
        float entrance_length {effect.at("entrance length")};
 | 
						|
 | 
						|
        /* Zoom entrance */
 | 
						|
        float entrance_completion {glm::smoothstep(0.0f, entrance_length, title_name_animation.elapsed())};
 | 
						|
        glm::vec2 effect_range { effect.at("entrance zoom range") };
 | 
						|
        effect_zoom = glm::mix(glm::radians(effect_range.x), glm::radians(effect_range.y), entrance_completion);
 | 
						|
 | 
						|
        /* Play SFX */
 | 
						|
        if (!title_name_animation_buildup_played
 | 
						|
            && title_name_animation.elapsed() >= effect.at("buildup delay").get<float>())
 | 
						|
        {
 | 
						|
            audio.at("buildup").play();
 | 
						|
            title_name_animation_buildup_played = true;
 | 
						|
        }
 | 
						|
        if (!title_name_animation_voice_played
 | 
						|
            && title_name_animation.elapsed() >= effect.at("voice delay").get<float>())
 | 
						|
        {
 | 
						|
            audio.at("cakefoot").play();
 | 
						|
            title_name_animation_voice_played = true;
 | 
						|
        }
 | 
						|
 | 
						|
        /* Rotate up effect */
 | 
						|
        float rotation_completion {
 | 
						|
            glm::smoothstep(entrance_length,
 | 
						|
                            entrance_length + effect.at("rotation length").get<float>(),
 | 
						|
                            title_name_animation.elapsed())
 | 
						|
        };
 | 
						|
        effect_rotation.y = glm::mix(glm::half_pi<float>(), 0.0f, rotation_completion);
 | 
						|
 | 
						|
        /* Launch replay playback for attract mode, effectively ending the title screen animation */
 | 
						|
        if (idle_timer.elapsed() >= effect.at("replay delay"))
 | 
						|
        {
 | 
						|
            cakefoot::Replay random_replay = random_attract_mode_replay();
 | 
						|
            if (!random_replay.empty())
 | 
						|
            {
 | 
						|
                launch_replay(random_replay);
 | 
						|
                attract_mode_active = true;
 | 
						|
                demo_timer.reset();
 | 
						|
                demo_timer.on();
 | 
						|
                return;
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                idle_timer.reset();
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Transformation for looking at the center of the field of play from the camera position. */
 | 
						|
    view = glm::lookAt(camera_position, {0.0f, 0.0f, 0.0f}, glm::vec3{0.0f, 1.0f, 0.0f});
 | 
						|
 | 
						|
    /* Transformation from camera space to clip space. Apply zoom to one transformation for the stage elements, and
 | 
						|
     * leave another without zoom for the UI. */
 | 
						|
    float fov;
 | 
						|
    if (window_box().aspect() >= curve().aspect)
 | 
						|
    {
 | 
						|
        fov = 2.0f * glm::atan(1.0f / camera_position.z);
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        fov = 2.0f * glm::atan(
 | 
						|
            ((1.0f / (window_box().width() * (9.0f / 16.0f))) * window_box().height()) / camera_position.z);
 | 
						|
    }
 | 
						|
    projection = glm::perspective(fov, window_box().aspect(), 0.1f, 100.0f);
 | 
						|
    zoom_projection = glm::perspective(fov + zoom + effect_zoom, window_box().aspect(), 0.1f, 100.f);
 | 
						|
 | 
						|
    /* Separate x and y axes. */
 | 
						|
    float rotation_x = rotation.x + effect_rotation.x;
 | 
						|
    float rotation_y = rotation.y + effect_rotation.y;
 | 
						|
 | 
						|
    /* Add a looping swivel, or a continuous rotation when paused. */
 | 
						|
    if (paused() || (replay_playback && replay_playback->ended()))
 | 
						|
    {
 | 
						|
        rotation_x += glm::mix(
 | 
						|
            0.0f,
 | 
						|
            glm::two_pi<float>(),
 | 
						|
            sb::math::loop(timestamp + auto_rotation_time_offset, configuration()("spin", "loop")));
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        float distance { configuration()("swivel", "distance") };
 | 
						|
        rotation_x += glm::mix(
 | 
						|
            -distance * glm::pi<float>(),
 | 
						|
            distance * glm::pi<float>(),
 | 
						|
            sb::math::ping_pong(timestamp, configuration()("swivel", "loop")));
 | 
						|
    }
 | 
						|
 | 
						|
    /* Rotate X 180 if mirror mode is active */
 | 
						|
    if (view_index == 1)
 | 
						|
    {
 | 
						|
        rotation_x += glm::pi<float>();
 | 
						|
    }
 | 
						|
    else if (view_index == 2)
 | 
						|
    {
 | 
						|
        glm::vec2 warp = configuration()("levels", level_index, "warp");
 | 
						|
        rotation_x += warp.x;
 | 
						|
        rotation_y += warp.y;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Transformation that applies the rotation state of the entire scene */
 | 
						|
    glm::mat4 rotation_matrix = glm::rotate(glm::mat4(1), rotation_x, {0.0f, 1.0f, 0.0f}) * glm::rotate(
 | 
						|
        glm::mat4(1), rotation_y, {1.0f, 0.0f, 0.0f});
 | 
						|
 | 
						|
    /* Character position in NDC */
 | 
						|
    glm::vec2 character_position = sb::math::wrap_point(
 | 
						|
        glm::vec3{character.box().center(), 0.0f}, {-curve().aspect, -1.0f, -1.0f}, {curve().aspect, 1.0f, 1.0f});
 | 
						|
    glm::vec3 character_ndc = sb::math::world_to_ndc(character_position, zoom_projection * view * rotation_matrix);
 | 
						|
 | 
						|
    /* Clear screen to world color */
 | 
						|
    if (flash_animation.playing())
 | 
						|
    {
 | 
						|
        sb::Color extra_shift = rotating_hue;
 | 
						|
        extra_shift.shift_hue(180.0f);
 | 
						|
        glm::vec4 clear = extra_shift.normal() / configuration()("display", "flash darken factor").get<float>() +
 | 
						|
            world_color;
 | 
						|
        glClearColor(clear.r, clear.g, clear.b, clear.a);
 | 
						|
    }
 | 
						|
    else if (arcade_limit_warning)
 | 
						|
    {
 | 
						|
        glm::vec4 clear = world_color + configuration()("display", "arcade warning color").get<glm::vec4>();
 | 
						|
        glClearColor(clear.r, clear.g, clear.b, clear.a);
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        glClearColor(world_color.r, world_color.g, world_color.b, world_color.a);
 | 
						|
    }
 | 
						|
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 | 
						|
 | 
						|
    /*!
 | 
						|
     * Update buttons and check for clicks.
 | 
						|
     *
 | 
						|
     * If a button is clicked, return immediately so that each button press runs before the rest of the game update and
 | 
						|
     * only runs once per button press.
 | 
						|
     *
 | 
						|
     * In the future, all buttons should be handled here (but in a Cakefoot::update function instead of Cakefoot::draw)
 | 
						|
     * in favor of using callbacks and Pad::on_state_change because of issues with copying the callbacks into new
 | 
						|
     * objects.
 | 
						|
     */
 | 
						|
 | 
						|
    /* Check replay launcher buttons */
 | 
						|
    for (std::size_t button_index = 0; button_index < replay_buttons.size(); button_index++)
 | 
						|
    {
 | 
						|
        sb::Pad<>& pad { replay_buttons[button_index] };
 | 
						|
        if (pad.update())
 | 
						|
        {
 | 
						|
            int level_index = replay_button_index_to_level(button_index);
 | 
						|
            cakefoot::Replay fastest { fastest_replay(level_index) };
 | 
						|
            if (!fastest.empty())
 | 
						|
            {
 | 
						|
                launch_replay(fastest);
 | 
						|
                return;
 | 
						|
            } } }
 | 
						|
 | 
						|
    /* Check replay end screen buttons */
 | 
						|
    if (button.at("replay restart").update())
 | 
						|
    {
 | 
						|
        replay_buttons.at(level_index_to_replay_button(level_index)).click();
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    if (button.at("replay exit").update())
 | 
						|
    {
 | 
						|
        sb::Delegate::post("reset");
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Ignore most of the update and draw loop if the play button is enabled and hasn't been pressed */
 | 
						|
    if (!use_play_button || button.at("play").pressed())
 | 
						|
    {
 | 
						|
        /* Continue ignoring while displaying the splash screen */
 | 
						|
        if (!splash_animation.playing())
 | 
						|
        {
 | 
						|
            /* End recording if it has gone over time limit */
 | 
						|
            if (replay_recording && replay_recording->length() > configuration()("replay", "max length"))
 | 
						|
            {
 | 
						|
                replay_recording.reset();
 | 
						|
                sb::Log::Line() << "Canceling replay recording because time limit exceeded";
 | 
						|
            }
 | 
						|
 | 
						|
            /* Update other timers */
 | 
						|
            unpaused_timer.update(timestamp);
 | 
						|
            run_timer.update(timestamp);
 | 
						|
            if (!replay_playback)
 | 
						|
            {
 | 
						|
                stat_progress.increment_stat(stats["STAT_PLAY_TIME"], run_timer.frame(), achievements, stats);
 | 
						|
                configuration()["progress"]["total time"].get_ref<nlohmann::json::number_float_t&>() += run_timer.frame();
 | 
						|
                if (arcade())
 | 
						|
                {
 | 
						|
                    configuration()["progress"]["arcade time"].get_ref<nlohmann::json::number_float_t&>() +=
 | 
						|
                        run_timer.frame();
 | 
						|
                }
 | 
						|
                else if (quest())
 | 
						|
                {
 | 
						|
                    configuration()["progress"]["quest time"].get_ref<nlohmann::json::number_float_t&>() +=
 | 
						|
                        run_timer.frame();
 | 
						|
                }
 | 
						|
                idle_timer.update(timestamp);
 | 
						|
                survival_timer.update(timestamp);
 | 
						|
                stray_timer.update(timestamp);
 | 
						|
            }
 | 
						|
 | 
						|
            /* In demo and arcade modes, reset game if idle timeout elapsed, or reset idle timer if character is
 | 
						|
             * accelerating. Prevent this when a replay is playing because that means attract mode is active. */
 | 
						|
            if (level_index > 0 && !replay_playback &&
 | 
						|
                ((configuration()("demo", "active") &&
 | 
						|
                  idle_timer.elapsed() > configuration()("demo", "idle timeout")) ||
 | 
						|
                 (configuration()("arcade", "arcade only") &&
 | 
						|
                  idle_timer.elapsed() > configuration()("arcade", "idle timeout"))))
 | 
						|
            {
 | 
						|
                sb::Delegate::post("reset");
 | 
						|
                stat_progress.increment_stat(stats["STAT_IDLE_RESETS"], 1, achievements, stats);
 | 
						|
            }
 | 
						|
            else if (character.accelerating)
 | 
						|
            {
 | 
						|
                idle_timer.reset();
 | 
						|
            }
 | 
						|
 | 
						|
            /* Check if the survival timer reached any milestones */
 | 
						|
            if (level_index == 10 && !replay_playback &&
 | 
						|
                survival_timer.elapsed() >= achievements["ACH_SHAKE_N_BAKE"].json().at("goal").get<float>())
 | 
						|
            {
 | 
						|
                stat_progress.unlock_achievement(achievements["ACH_SHAKE_N_BAKE"]);
 | 
						|
                if (!stat_progress.achievement_unlocked(achievements["ACH_SHAKE_N_BAKE"]))
 | 
						|
                {
 | 
						|
                    progress_writer.stage(true, true);
 | 
						|
                }
 | 
						|
            }
 | 
						|
            else if (level_index == 14 && !replay_playback)
 | 
						|
            {
 | 
						|
                /* Check the position of the character. If it is outside of a fish ring, reset the survival clock.
 | 
						|
                 * Only keep the clock going when the character is inside of a fish ring. When the survival clock
 | 
						|
                 * reaches the goal, unlock the achievement. */
 | 
						|
                float position = character.relative(curve());
 | 
						|
                const nlohmann::json rings = achievements["ACH_FISHCAKE"].json().at("rings");
 | 
						|
                if (position < rings[0][0] || (position > rings[0][1] && position < rings[1][0]) ||
 | 
						|
                    position > rings[1][1])
 | 
						|
                {
 | 
						|
                    survival_timer.reset();
 | 
						|
                }
 | 
						|
                else if (survival_timer.elapsed() > achievements["ACH_FISHCAKE"].json().at("goal").get<float>())
 | 
						|
                {
 | 
						|
                    stat_progress.unlock_achievement(achievements["ACH_FISHCAKE"]);
 | 
						|
                    if (!stat_progress.achievement_unlocked(achievements["ACH_FISHCAKE"]))
 | 
						|
                    {
 | 
						|
                        progress_writer.stage(true, true);
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            /* Distance tracking */
 | 
						|
            float check = distance();
 | 
						|
            if (!replay_playback && static_cast<std::size_t>(level_index) < configuration()("levels").size() - 1)
 | 
						|
            {
 | 
						|
                if (check > stat_progress.stat_default(stats["STAT_FARTHEST_DISTANCE_REACHED"], 0.0f))
 | 
						|
                {
 | 
						|
                    stat_progress.set_stat(stats["STAT_FARTHEST_DISTANCE_REACHED"], check);
 | 
						|
                }
 | 
						|
                if (arcade() && check > stat_progress.stat_default(stats["STAT_FARTHEST_ARCADE_DISTANCE"], 0.0f))
 | 
						|
                {
 | 
						|
                    /* Record as a stat because "arcade max distance" is deprecated in favor of stats */
 | 
						|
                    stat_progress.set_stat(stats["STAT_FARTHEST_ARCADE_DISTANCE"], check);
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            /* Arcade scoring */
 | 
						|
            int maximum_distance { configuration()("progress", "arcade max distance") };
 | 
						|
            float extended_limit = limit();
 | 
						|
            if (arcade())
 | 
						|
            {
 | 
						|
                /* Check if maximum distance increased. Using auto as the type handles differences between integer types
 | 
						|
                 * in different compilers. */
 | 
						|
                if (check > maximum_distance)
 | 
						|
                {
 | 
						|
                    maximum_distance = check;
 | 
						|
                    configuration()["progress"]["arcade max distance"] = maximum_distance;
 | 
						|
                }
 | 
						|
 | 
						|
                /* End run if there is a time limit and the time limit is passed. Queue end level to load after a
 | 
						|
                 * delay. */
 | 
						|
                bool game_over_active = arcade() && level_index > 0 &&
 | 
						|
                    run_timer.elapsed() > extended_limit &&
 | 
						|
                    static_cast<std::size_t>(level_index) < configuration()("levels").size() - 1;
 | 
						|
                if (game_over_active && !game_over_animation.playing())
 | 
						|
                {
 | 
						|
                    run_timer.off();
 | 
						|
                    arcade_limit_warning = false;
 | 
						|
 | 
						|
                    /* Play once with a delay to let the game over screen display temporarily before the end level is
 | 
						|
                     * loaded. */
 | 
						|
                    game_over_animation.play_once(configuration()("display", "game over display time"));
 | 
						|
 | 
						|
                    /* Create arcade score */
 | 
						|
                    set_arcade_score(extended_limit, maximum_distance);
 | 
						|
 | 
						|
                    /* Record a run */
 | 
						|
                    stat_progress.increment_stat(stats["STAT_ARCADE_RUNS"], 1, achievements, stats);
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            /* Update achievement pop up animation */
 | 
						|
            if (achievements_pop_up_animation.update(timestamp))
 | 
						|
            {
 | 
						|
                /* Pop up is expired */
 | 
						|
                achievements_pop_up_text = "";
 | 
						|
            }
 | 
						|
 | 
						|
            /* Check if a new achievement has been recorded or a new fastest replay has been added, and trigger a pop up
 | 
						|
             * if so. */
 | 
						|
            if (!stat_progress.live_achievements().empty() || fastest_replay_added)
 | 
						|
            {
 | 
						|
                for (const std::string& id : stat_progress.live_achievements())
 | 
						|
                {
 | 
						|
                   sb::progress::Achievement achievement = achievements[id];
 | 
						|
 | 
						|
                   /* Add a line break if there is existing text */
 | 
						|
                   if (!achievements_pop_up_text.empty())
 | 
						|
                   {
 | 
						|
                       achievements_pop_up_text += "\n";
 | 
						|
                   }
 | 
						|
 | 
						|
                   achievements_pop_up_text += achievement.name() + ": " + achievement.description();
 | 
						|
                }
 | 
						|
 | 
						|
                /* Clear the live achievements */
 | 
						|
                stat_progress.live_achievements(true);
 | 
						|
 | 
						|
                /* Add a line for the replay */
 | 
						|
                if (fastest_replay_added)
 | 
						|
                {
 | 
						|
                   /* Add a line break if there is existing text */
 | 
						|
                   if (!achievements_pop_up_text.empty())
 | 
						|
                   {
 | 
						|
                       achievements_pop_up_text += "\n";
 | 
						|
                   }
 | 
						|
 | 
						|
                   /* Add the replay message to the pop up */
 | 
						|
                   std::string text { configuration()("replay", "pop up message") };
 | 
						|
                   std::ostringstream level;
 | 
						|
                   int index { level_select() ? level_index : level_index - 1 };
 | 
						|
                   level << index;
 | 
						|
                   text.replace(text.find("{level}"), std::string("{level}").size(), level.str());
 | 
						|
                   achievements_pop_up_text += text;
 | 
						|
 | 
						|
                   /* Reset flag */
 | 
						|
                   fastest_replay_added = false;
 | 
						|
                }
 | 
						|
 | 
						|
                /* Load new graphics */
 | 
						|
                load_achievements_pop_up();
 | 
						|
 | 
						|
                /* If the timeout is already playing, this will start it from the beginning */
 | 
						|
                achievements_pop_up_animation.reset();
 | 
						|
 | 
						|
                /* Trigger the pop up to disappear in 5 seconds */
 | 
						|
                achievements_pop_up_animation.play_once(5.0);
 | 
						|
            }
 | 
						|
 | 
						|
            /* Freeze screen while game over display is active. */
 | 
						|
            if (!game_over_animation.playing())
 | 
						|
            {
 | 
						|
                /* Update character, along the curve, using the timer to determine movement since last frame, and update
 | 
						|
                 * enemies. Check for collison as enemies are updated. */
 | 
						|
                std::optional<float> constant_speed;
 | 
						|
                if (operator_menu_active)
 | 
						|
                {
 | 
						|
                    constant_speed = 0.0f;
 | 
						|
                }
 | 
						|
                else if (level_index == 0)
 | 
						|
                {
 | 
						|
                    constant_speed = configuration()("character", "idle speed");
 | 
						|
                }
 | 
						|
                else if (replay_playback)
 | 
						|
                {
 | 
						|
                    constant_speed = character.speed();
 | 
						|
                }
 | 
						|
                character.update(
 | 
						|
                    curve(),
 | 
						|
                    unpaused_timer,
 | 
						|
                    character_ndc,
 | 
						|
                    !button.at("volume").pressed() || operator_menu_active || level_index == 0,
 | 
						|
                    constant_speed
 | 
						|
                    );
 | 
						|
                if (character.at_end(curve()) && !replay_playback)
 | 
						|
                {
 | 
						|
                    /* On the ending screen, submit the score and name entry. */
 | 
						|
                    if (arcade() && static_cast<std::size_t>(level_index) == configuration()("levels").size() - 1)
 | 
						|
                    {
 | 
						|
                        submit_score();
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        /* On the final arcade level, create a score since run is complete. Also unlock ACH_HOTCAKES.
 | 
						|
                         * If the time is faster than the recorded fastest time stat, record the time. */
 | 
						|
                        if (arcade() && static_cast<std::size_t>(level_index) == configuration()("levels").size() - 2)
 | 
						|
                        {
 | 
						|
                            set_arcade_score(extended_limit, maximum_distance);
 | 
						|
 | 
						|
                            /* Record stats and achievements */
 | 
						|
                            stat_progress.increment_stat(stats["STAT_ARCADE_RUNS"], 1, achievements, stats);
 | 
						|
                            stat_progress.unlock_achievement(achievements["ACH_HOTCAKES"]);
 | 
						|
                            if (!stat_progress.stat_exists(stats["STAT_BEST_ARCADE_CLOCK"]) ||
 | 
						|
                                arcade_score.time < stat_progress.stat_value(stats["STAT_BEST_ARCADE_CLOCK"]))
 | 
						|
                            {
 | 
						|
                                stat_progress.set_stat(
 | 
						|
                                    stats["STAT_BEST_ARCADE_CLOCK"], arcade_score.time, achievements, stats);
 | 
						|
                            }
 | 
						|
 | 
						|
                            /* Save and sync */
 | 
						|
                            progress_writer.stage(true, true);
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* If warped view is on, mark the level as beaten on warped mode */
 | 
						|
                        if (level_index > 0 &&
 | 
						|
                            static_cast<std::size_t>(level_index) < configuration()("levels").size() - 1 &&
 | 
						|
                            configuration()("view").at(progress<int>("current view")).at("name") == "WARPED")
 | 
						|
                        {
 | 
						|
                            nlohmann::json::string_t& warped_record =
 | 
						|
                                configuration()["progress"]["warped"].get_ref<nlohmann::json::string_t&>();
 | 
						|
                            if (std::string(1, warped_record[level_index - 1]) ==
 | 
						|
                                configuration()("warped", "level unbeaten").get<std::string>())
 | 
						|
                            {
 | 
						|
                                warped_record[level_index - 1] =
 | 
						|
                                    configuration()("warped", "level beaten").get<std::string>()[0];
 | 
						|
                                stat_progress.increment_stat(
 | 
						|
                                    stats["STAT_WARPED_LEVELS_BEATEN"], 1, achievements, stats);
 | 
						|
                                progress_writer.stage(true, true);
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* Check the level 7 achievement for returning the coin to the beginning and making it to the end
 | 
						|
                         * without dying */
 | 
						|
                        if (level_index == 7 && coin_returned)
 | 
						|
                        {
 | 
						|
                            stat_progress.unlock_achievement(achievements["ACH_BOOMERINGUE"]);
 | 
						|
                            if (!stat_progress.achievement_unlocked(achievements["ACH_BOOMERINGUE"]))
 | 
						|
                            {
 | 
						|
                                progress_writer.stage(true, true);
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* Collect any previously taken coins */
 | 
						|
                        collect_coin();
 | 
						|
 | 
						|
                        /* Check the achievement for beating level 6 without letting go of the button and getting the
 | 
						|
                         * coin. */
 | 
						|
                        if (!let_go && level_index == 6 && coin_collected)
 | 
						|
                        {
 | 
						|
                            stat_progress.unlock_achievement(achievements["ACH_COFFEE_CAKE"]);
 | 
						|
                            if (!stat_progress.achievement_unlocked(achievements["ACH_COFFEE_CAKE"]))
 | 
						|
                            {
 | 
						|
                                progress_writer.stage(true, true);
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* End the replay */
 | 
						|
                        if (replay_recording && !replay_recording->empty())
 | 
						|
                        {
 | 
						|
                            /* Add end event */
 | 
						|
                            replay_recording->record({
 | 
						|
                                    run_timer,
 | 
						|
                                    character.translation(),
 | 
						|
                                    character.mirrored(curve()),
 | 
						|
                                    cakefoot::Replay::end});
 | 
						|
 | 
						|
                            /* Save to JSON */
 | 
						|
                            store_replay_recording(replay_recording.value(), level_index);
 | 
						|
                            replay_recording.reset();
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* Load next level, or reload current level if in level select mode or on title screen */
 | 
						|
                        audio.at("teleport").pan(character_ndc.x);
 | 
						|
                        audio.at("teleport").play();
 | 
						|
                        load_level(level_select() || level_index == 0 ? level_index : level_index + 1);
 | 
						|
 | 
						|
#if defined(__COOLMATH__)
 | 
						|
                        /* Trigger an ad when a level is beaten */
 | 
						|
                        if (level_index > 0 &&
 | 
						|
                            static_cast<std::size_t>(level_index) < configuration()("levels").size() - 1)
 | 
						|
                        {
 | 
						|
                            EM_ASM(cmgAdBreak());
 | 
						|
                        }
 | 
						|
#endif
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    /* If the character is resting or a replay is playing, the survival clock shouldn't be counting */
 | 
						|
                    if (character.resting() || replay_playback)
 | 
						|
                    {
 | 
						|
                        survival_timer.reset();
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Update continuous timer for longest walk stat and achievements. Turn on the let_go flag if
 | 
						|
                     * character ever stops accelerating for the coffee cake achievement. */
 | 
						|
                    continuous_timer.update(timestamp);
 | 
						|
                    if (character.accelerating && level_index > 0 && !replay_playback)
 | 
						|
                    {
 | 
						|
                        continuous_timer.on();
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        continuous_timer.reset();
 | 
						|
                        continuous_timer.off();
 | 
						|
                        if (!character.resting())
 | 
						|
                        {
 | 
						|
                            let_go = true;
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    if (!replay_playback && continuous_timer.elapsed() >
 | 
						|
                        stat_progress.stat_default(stats["STAT_LONGEST_CONTINUOUS_WALK"], 0.0f))
 | 
						|
                    {
 | 
						|
                        sb::Log::Multi(sb::Log::DEBUG) << "New continuous max " << continuous_timer.elapsed() <<
 | 
						|
                            sb::Log::end;
 | 
						|
                        stat_progress.set_stat(
 | 
						|
                            stats["STAT_LONGEST_CONTINUOUS_WALK"], continuous_timer.elapsed(), achievements, stats);
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Update checkpoint */
 | 
						|
                    if (profile_index == 0
 | 
						|
                        && configuration()("levels", level_index).contains("checkpoints")
 | 
						|
                        && !replay_playback)
 | 
						|
                    {
 | 
						|
                        for (nlohmann::json checkpoint : configuration()("levels", level_index, "checkpoints"))
 | 
						|
                        {
 | 
						|
                            if (character.relative(curve()) >= checkpoint["position"].get<float>() &&
 | 
						|
                                character.checkpoint() < checkpoint["position"].get<float>())
 | 
						|
                            {
 | 
						|
                                audio.at("checkpoint").pan(character_ndc.x);
 | 
						|
                                audio.at("checkpoint").play();
 | 
						|
                                character.checkpoint(checkpoint["position"].get<float>());
 | 
						|
 | 
						|
                                /* Record a checkpoint event to replay */
 | 
						|
                                if (replay_recording)
 | 
						|
                                {
 | 
						|
                                    replay_recording->record({
 | 
						|
                                            run_timer,
 | 
						|
                                            character.translation(),
 | 
						|
                                            character.mirrored(curve()),
 | 
						|
                                            cakefoot::Replay::checkpoint});
 | 
						|
                                }
 | 
						|
 | 
						|
                                /* Collect any previously taken coins */
 | 
						|
                                collect_coin();
 | 
						|
 | 
						|
                                /* Record stat */
 | 
						|
                                stat_progress.increment_stat(stats["STAT_CHECKPOINTS_REACHED"]);
 | 
						|
 | 
						|
                                /* Save progress */
 | 
						|
                                if (arcade())
 | 
						|
                                {
 | 
						|
                                    configuration()["progress"]["arcade checkpoint"] = character.checkpoint();
 | 
						|
                                }
 | 
						|
                                else if (quest())
 | 
						|
                                {
 | 
						|
                                    configuration()["progress"]["quest checkpoint"] = character.checkpoint();
 | 
						|
                                }
 | 
						|
                                progress_writer.stage();
 | 
						|
                            } } }
 | 
						|
 | 
						|
                    /* Check for Icing achievement on level 2 */
 | 
						|
                    if (level_index == 2 && !replay_playback)
 | 
						|
                    {
 | 
						|
                        float first_checkpoint = configuration()("levels", 2, "checkpoints")[0].at("position");
 | 
						|
                        float second_checkpoint = configuration()("levels", 2, "checkpoints")[1].at("position");
 | 
						|
                        float position = character.relative(curve());
 | 
						|
                        if (position >= second_checkpoint)
 | 
						|
                        {
 | 
						|
                            icing_available = true;
 | 
						|
                        }
 | 
						|
                        else if (character.accelerating)
 | 
						|
                        {
 | 
						|
                            icing_available = false;
 | 
						|
                        }
 | 
						|
                        if (icing_available && character.checkpoint() >= second_checkpoint &&
 | 
						|
                            position <= first_checkpoint)
 | 
						|
                        {
 | 
						|
                            stat_progress.unlock_achievement(achievements["ACH_ICING"]);
 | 
						|
                            if (!stat_progress.achievement_unlocked(achievements["ACH_ICING"]))
 | 
						|
                            {
 | 
						|
                                progress_writer.stage(true, true);
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* No-clip mode can be activated by mocks of the Cakefoot class for testing without collisions */
 | 
						|
                    bool enemy_collision = false;
 | 
						|
                    bool coin_taken = false;
 | 
						|
                    bool wanderer = false;
 | 
						|
                    std::string killer;
 | 
						|
                    if (!noclip)
 | 
						|
                    {
 | 
						|
                        /* Collide with enemies and challenge coins */
 | 
						|
                        glm::vec3 clip_upper {-curve().aspect, -1.0f, -1.0f}, clip_lower {curve().aspect, 1.0f, 1.0f};
 | 
						|
                        for (auto enemy_iterator = enemies.begin(); enemy_iterator != enemies.end(); enemy_iterator++)
 | 
						|
                        {
 | 
						|
                            auto& enemy = *enemy_iterator;
 | 
						|
                            enemy->update(unpaused_timer);
 | 
						|
 | 
						|
                            if (!replay_playback)
 | 
						|
                            {
 | 
						|
                                if (enemy->collide(character.box(), character.sprite(), clip_upper, clip_lower))
 | 
						|
                                {
 | 
						|
                                    enemy_collision = true;
 | 
						|
                                    killer = enemy->stat_id();
 | 
						|
                                    wanderer = enemy->wanderer;
 | 
						|
                                }
 | 
						|
                                else if (enemy->collide_coin(character.box(), clip_upper, clip_lower) && !character.resting())
 | 
						|
                                {
 | 
						|
                                    audio.at("take").pan(character_ndc.x);
 | 
						|
                                    audio.at("take").play();
 | 
						|
                                    enemy->take_coin();
 | 
						|
 | 
						|
                                    /* Record coin event to replay */
 | 
						|
                                    if (replay_recording)
 | 
						|
                                    {
 | 
						|
                                        replay_recording->record({
 | 
						|
                                                run_timer,
 | 
						|
                                                character.translation(),
 | 
						|
                                                character.mirrored(curve()),
 | 
						|
                                                cakefoot::Replay::coin});
 | 
						|
                                    }
 | 
						|
                                }
 | 
						|
 | 
						|
                                /* Update coin position if the character is currently holding it */
 | 
						|
                                if (!coin_collected && enemy->coin_taken())
 | 
						|
                                {
 | 
						|
                                    glm::vec2 location = character.box().center() +
 | 
						|
                                        configuration()("display", "loot offset").get<glm::vec2>();
 | 
						|
                                    enemy->coin_translation(curve().wrap({location.x, location.y, 0.0f}));
 | 
						|
 | 
						|
                                    /* Save the state to be used later in the function */
 | 
						|
                                    coin_taken = true;
 | 
						|
                                } } } }
 | 
						|
 | 
						|
                    /* Collide with ending screen coins */
 | 
						|
                    if (end_screen())
 | 
						|
                    {
 | 
						|
                        ending_coins.erase(
 | 
						|
                            std::remove_if(
 | 
						|
                                ending_coins.begin(),
 | 
						|
                                ending_coins.end(),
 | 
						|
                                [&](Flame& coin)
 | 
						|
                                {
 | 
						|
                                    if (coin.collide(character.box(), character.sprite(),
 | 
						|
                                                     {-1.0f, -1.0f, -1.0f}, {1.0f, 1.0f, 1.0f}))
 | 
						|
                                    {
 | 
						|
                                        sb::audio::Chunk& sfx = coin.mask() ? audio.at("take") : audio.at("bong");
 | 
						|
                                        sfx.pan(character_ndc.x);
 | 
						|
                                        sfx.play();
 | 
						|
                                        return true;
 | 
						|
                                    }
 | 
						|
                                    return false;
 | 
						|
                                }),
 | 
						|
                            ending_coins.end());
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Respawn */
 | 
						|
                    if (!character.resting() && !replay_playback && enemy_collision)
 | 
						|
                    {
 | 
						|
                        /* Record a death using the collision check that happened earlier */
 | 
						|
                        std::string id = "STAT_" + killer + "_DEATHS";
 | 
						|
                        stat_progress.increment_stat(stats[id], 1, achievements, stats);
 | 
						|
 | 
						|
                        /* Note: nlohmann::json::get_ref returns inconsistent results between
 | 
						|
                         * get_ref<nlohmann::json::number_unsigned_t&> and
 | 
						|
                         * get_ref<nlohmann::json::number_integer_t&> so accessing by value instead. */
 | 
						|
                        std::string key { quest() ? "quest deaths" : "arcade deaths" };
 | 
						|
                        configuration()["progress"][key] = configuration()("progress", key).get<int>() + 1;
 | 
						|
 | 
						|
                        /* Check for the achievement for dying at the end of the line */
 | 
						|
                        if (character.relative(curve()) >= achievements["ACH_DOHNUT"].json().at("goal").get<float>())
 | 
						|
                        {
 | 
						|
                            stat_progress.unlock_achievement(achievements["ACH_DOHNUT"]);
 | 
						|
                            if (!stat_progress.achievement_unlocked(achievements["ACH_DOHNUT"]))
 | 
						|
                            {
 | 
						|
                                progress_writer.stage(true, true);
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* Check for wandering fish achievement */
 | 
						|
                        if (wanderer)
 | 
						|
                        {
 | 
						|
                            stat_progress.unlock_achievement(achievements["ACH_JUST_DESSERTS"]);
 | 
						|
                            if (!stat_progress.achievement_unlocked(achievements["ACH_JUST_DESSERTS"]))
 | 
						|
                            {
 | 
						|
                                progress_writer.stage(true, true);
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
 | 
						|
                        audio.at("restart").pan(character_ndc.x);
 | 
						|
                        audio.at("restart").play();
 | 
						|
                        character.spawn(curve());
 | 
						|
                        for (auto& enemy : enemies)
 | 
						|
                        {
 | 
						|
                            enemy->reset();
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* Reset coin returned to beginning status */
 | 
						|
                        coin_returned = false;
 | 
						|
                        coin_taken = false;
 | 
						|
 | 
						|
                        /* Reset survival and stray clocks */
 | 
						|
                        survival_timer.reset();
 | 
						|
                        stray_timer.reset();
 | 
						|
 | 
						|
                        /* Save progress */
 | 
						|
                        progress_writer.stage();
 | 
						|
 | 
						|
                        /* Reset distance tracking */
 | 
						|
                        previous_distance.reset();
 | 
						|
 | 
						|
                        /* Record to replay */
 | 
						|
                        if (replay_recording)
 | 
						|
                        {
 | 
						|
                            replay_recording->record({
 | 
						|
                                    run_timer,
 | 
						|
                                    character.translation(),
 | 
						|
                                    character.mirrored(curve()),
 | 
						|
                                    cakefoot::Replay::collision});
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    else if (replay_recording && replay_record_animation.update(run_timer))
 | 
						|
                    {
 | 
						|
                        replay_recording->record({run_timer, character.translation(), character.mirrored(curve())});
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Keep a count of meters walked */
 | 
						|
                    if (previous_distance && !replay_playback)
 | 
						|
                    {
 | 
						|
                        float change = std::abs(distance_float() - previous_distance.value());
 | 
						|
                        stat_progress.increment_stat(stats["STAT_DISTANCE_TRAVELED"], change);
 | 
						|
                    }
 | 
						|
                    previous_distance = distance_float();
 | 
						|
 | 
						|
                    /* Check if the character speed has stayed close enough to 0 for Batter Up achievement. */
 | 
						|
                    if (!character.resting() && !replay_playback && level_index != 0)
 | 
						|
                    {
 | 
						|
                        if (stray_timer.elapsed() > stat_progress.stat_default(stats["STAT_STRAY_TIMER_MAX"], 0.0f))
 | 
						|
                        {
 | 
						|
                            sb::Log::Multi(sb::Log::DEBUG) << "New stray max " << stray_timer.elapsed() <<
 | 
						|
                                sb::Log::end;
 | 
						|
                            stat_progress.set_stat(
 | 
						|
                                stats["STAT_STRAY_TIMER_MAX"], stray_timer.elapsed(), achievements, stats);
 | 
						|
                        }
 | 
						|
                        stray += character.speed();
 | 
						|
                        if (std::abs(stray) > achievements["ACH_BATTER_UP"].json().at("range").get<float>())
 | 
						|
                        {
 | 
						|
                            stray = 0.0f;
 | 
						|
                            stray_timer.reset();
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        stray_timer.reset();
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* If a replay is playing, after all movement has been processed, sync the movement with the replay.
 | 
						|
                     */
 | 
						|
                    if (replay_playback)
 | 
						|
                    {
 | 
						|
                        if (attract_mode_active && demo_timer.elapsed() > configuration()("attract", "demo length"))
 | 
						|
                        {
 | 
						|
                            /* End attract mode demo if time limit reached */
 | 
						|
                            load_level(0);
 | 
						|
                            idle_timer.reset();
 | 
						|
                            return;
 | 
						|
                        }
 | 
						|
                        else if (replay_playback_animation.update(run_timer))
 | 
						|
                        {
 | 
						|
                            std::vector<cakefoot::Replay::KeyFrame> frames { replay_playback->unread(run_timer) };
 | 
						|
 | 
						|
                            for (cakefoot::Replay::KeyFrame frame : frames)
 | 
						|
                            {
 | 
						|
                                glm::vec3 ndc = sb::math::world_to_ndc(
 | 
						|
                                    frame.translation, zoom_projection * view * rotation_matrix);
 | 
						|
                                if (frame.event == cakefoot::Replay::collision)
 | 
						|
                                {
 | 
						|
                                    audio.at("restart").pan(ndc.x);
 | 
						|
                                    audio.at("restart").play();
 | 
						|
                                    character.spawn(curve());
 | 
						|
                                    for (auto& enemy : enemies)
 | 
						|
                                    {
 | 
						|
                                        enemy->reset();
 | 
						|
                                    }
 | 
						|
                                }
 | 
						|
                                else if (frame.event == cakefoot::Replay::coin)
 | 
						|
                                {
 | 
						|
                                    audio.at("take").pan(ndc.x);
 | 
						|
                                    audio.at("take").play();
 | 
						|
                                    for (auto& enemy : enemies)
 | 
						|
                                    {
 | 
						|
                                        enemy->take_coin();
 | 
						|
                                    }
 | 
						|
                                }
 | 
						|
                                else if (frame.event == cakefoot::Replay::checkpoint)
 | 
						|
                                {
 | 
						|
                                    audio.at("checkpoint").pan(ndc.x);
 | 
						|
                                    audio.at("checkpoint").play();
 | 
						|
                                }
 | 
						|
                                else if (frame.event == cakefoot::Replay::collect)
 | 
						|
                                {
 | 
						|
                                    for (auto& enemy : enemies)
 | 
						|
                                    {
 | 
						|
                                        enemy->collect_coin();
 | 
						|
                                    }
 | 
						|
                                }
 | 
						|
                                else if (frame.event == cakefoot::Replay::end)
 | 
						|
                                {
 | 
						|
                                    run_timer.off();
 | 
						|
 | 
						|
                                    /* Offset the rotation animation so that it is now at neutral, so the replay stage
 | 
						|
                                     * end rotation effect starts rotating from the beginning of the rotation.  */
 | 
						|
                                    auto_rotation_time_offset = -timestamp;
 | 
						|
 | 
						|
                                    /* If attract mode is active, run another replay automatically */
 | 
						|
                                    if (attract_mode_active)
 | 
						|
                                    {
 | 
						|
                                        cakefoot::Replay random_replay = random_attract_mode_replay(level_index);
 | 
						|
                                        if (!random_replay.empty())
 | 
						|
                                        {
 | 
						|
                                            launch_replay(random_replay);
 | 
						|
                                            return;
 | 
						|
                                        }
 | 
						|
                                        else
 | 
						|
                                        {
 | 
						|
                                            load_level(0);
 | 
						|
                                            idle_timer.reset();
 | 
						|
                                        }
 | 
						|
                                    }
 | 
						|
                                }
 | 
						|
 | 
						|
                                /* Move the character and approximate speed */
 | 
						|
                                character.translate(frame.translation, true);
 | 
						|
                                character.speed(frame.speed);
 | 
						|
 | 
						|
                                /* Move the coin */
 | 
						|
                                if (replay_playback->coin_taken && !replay_playback->coin_collected)
 | 
						|
                                {
 | 
						|
                                    for (auto& enemy : enemies)
 | 
						|
                                    {
 | 
						|
                                        glm::vec2 translation {
 | 
						|
                                            frame.translation
 | 
						|
                                            + configuration()("display", "loot offset").get<glm::vec2>()
 | 
						|
                                        };
 | 
						|
                                        enemy->coin_translation({translation.x, translation.y, 0.0f});
 | 
						|
                                    }
 | 
						|
                                }
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                        else
 | 
						|
                        {
 | 
						|
                            /* Always the speed so that the character movement looks correct even when the replay
 | 
						|
                             * playback speed is different from the frame rate of the program */
 | 
						|
                            character.speed(replay_playback->last_read().speed);
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Save progress and stats periodically when the game is idle. Update consecutive days played
 | 
						|
                     * count. */
 | 
						|
                    if (save_stats && (character.resting() || level_index == 0) && !replay_playback)
 | 
						|
                    {
 | 
						|
                        /* Observe new timestamp and day */
 | 
						|
                        std::chrono::time_point<std::chrono::system_clock> now { std::chrono::system_clock::now() };
 | 
						|
                        long timestamp { sb::time::epoch_minutes(now) };
 | 
						|
                        std::string day { sb::time::day_stamp(now) };
 | 
						|
 | 
						|
                        try
 | 
						|
                        {
 | 
						|
                            /* Check if newly observed day is different than the recorded day. */
 | 
						|
                            if (day != stat_progress.read("day").get<std::string>())
 | 
						|
                            {
 | 
						|
                                /* Check if difference since last recorded time is less than 48 hrs, and either add a
 | 
						|
                                 * consecutive day played or reset the counter. */
 | 
						|
                                if (timestamp - stat_progress.read("timestamp").get<long>() <= 48 * 60)
 | 
						|
                                {
 | 
						|
                                    stat_progress.increment_stat(
 | 
						|
                                        stats["STAT_CONSECUTIVE_DAYS_PLAYED"], 1, achievements, stats);
 | 
						|
                                }
 | 
						|
                                else
 | 
						|
                                {
 | 
						|
                                    stat_progress.set_stat(
 | 
						|
                                        stats["STAT_CONSECUTIVE_DAYS_PLAYED"], 1, achievements, stats);
 | 
						|
                                }
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                        catch (const std::runtime_error& error)
 | 
						|
                        {
 | 
						|
                            /* An expected error is the "day" and "timestamp" fields are missing on the first run,
 | 
						|
                             * which will be fixed by the values set below. */
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* Check if a date-based achievement should be unlocked */
 | 
						|
                        validate_date(now);
 | 
						|
 | 
						|
                        /* Record new timestamp and day and save */
 | 
						|
                        stat_progress.set(timestamp, "timestamp");
 | 
						|
                        stat_progress.set(day, "day");
 | 
						|
                        progress_writer.stage();
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Record if coin has been brought back to the beginning for levels which have an achievement for
 | 
						|
                     * it. */
 | 
						|
                    if (coin_taken && character.at_beginning(curve()) && !replay_playback)
 | 
						|
                    {
 | 
						|
                        coin_returned = true;
 | 
						|
                        if (level_index == 13)
 | 
						|
                        {
 | 
						|
                            stat_progress.unlock_achievement(achievements["ACH_BACK_IN_THE_OVEN"]);
 | 
						|
                        }
 | 
						|
                        else if (level_index == 11)
 | 
						|
                        {
 | 
						|
                            stat_progress.unlock_achievement(achievements["ACH_MIDNIGHT_SNACK"]);
 | 
						|
                        } } } }
 | 
						|
 | 
						|
            /*!
 | 
						|
             * Trigger the write at the end of the update section, before the draw section. If a write has been staged,
 | 
						|
             * this will run, and it will skip if not.
 | 
						|
             */
 | 
						|
            progress_writer.trigger();
 | 
						|
 | 
						|
            /* Plane position vertices will be used for everything before the curve */
 | 
						|
            sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
 | 
						|
            /* Ignore most drawing if the operator menu is active. */
 | 
						|
            if (operator_menu_active)
 | 
						|
            {
 | 
						|
                draw_operator_menu(view, projection, uniform);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                /* Disable texture */
 | 
						|
                glUniform1i(uniform.at("texture_enabled"), false);
 | 
						|
 | 
						|
                /* Enable background shader */
 | 
						|
                glUniform1i(uniform.at("bg_enabled"), true);
 | 
						|
                glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &glm::mat4(1)[0][0]);
 | 
						|
                sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
                sb::Plane::color->bind("vertex_color", shader_program);
 | 
						|
                sb::Plane::position->enable();
 | 
						|
                sb::Plane::color->enable();
 | 
						|
                glDrawArrays(GL_TRIANGLES, 0, sb::Plane::position->count());
 | 
						|
                sb::Plane::position->disable();
 | 
						|
                sb::Plane::color->disable();
 | 
						|
                glUniform1i(uniform.at("bg_enabled"), false);
 | 
						|
 | 
						|
                /* On the title screen, use a custom color addition which rotates the hue of the curve. Subtract some
 | 
						|
                 * RGB from the color addition, then add in the rotating hue to give the curve some of the color of the
 | 
						|
                 * hue. */
 | 
						|
                if (level_index == 0)
 | 
						|
                {
 | 
						|
                    curve_color_addition = glm::vec4 {0.05f, 0.05f, 0.05f, 0.0f} + rotating_hue.normal();
 | 
						|
                    glUniform4fv(uniform.at("color_addition"), 1, &curve_color_addition[0]);
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    if (flash_animation.playing())
 | 
						|
                    {
 | 
						|
                        glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                /* Draw the curve. Turn on alpha_mod just for curve. */
 | 
						|
                vp = zoom_projection * view * rotation_matrix;
 | 
						|
                glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &vp[0][0]);
 | 
						|
                curve().color.bind("vertex_color", shader_program);
 | 
						|
                curve().color.enable();
 | 
						|
                glUniform1i(uniform.at("alpha_mod_enabled"), true);
 | 
						|
                for (sb::Attributes& position : curve().position)
 | 
						|
                {
 | 
						|
                    position.bind("vertex_position", shader_program);
 | 
						|
                    position.enable();
 | 
						|
                    glDrawArrays(GL_LINE_STRIP, 0, position.count());
 | 
						|
                    position.disable();
 | 
						|
                }
 | 
						|
                glUniform1i(uniform.at("alpha_mod_enabled"), false);
 | 
						|
                curve().color.disable();
 | 
						|
 | 
						|
                /* Bind plane attributes */
 | 
						|
                sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
                sb::Plane::color->bind("vertex_color", shader_program);
 | 
						|
 | 
						|
                /* Reset color addition necessary for title screen */
 | 
						|
                if (level_index == 0)
 | 
						|
                {
 | 
						|
                    if (flash_animation.playing())
 | 
						|
                    {
 | 
						|
                        glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                /* Draw checkpoints */
 | 
						|
                if (profile_index == 0 && configuration()("levels", level_index).contains("checkpoints"))
 | 
						|
                {
 | 
						|
                    int index = 0;
 | 
						|
                    for (nlohmann::json checkpoint : configuration()("levels", level_index, "checkpoints"))
 | 
						|
                    {
 | 
						|
                        sb::Sprite* sprite;
 | 
						|
                        if (checkpoint["position"].get<float>() > character.checkpoint()
 | 
						|
                            && !(replay_playback && replay_playback->checkpoints > index))
 | 
						|
                        {
 | 
						|
                            sprite = &checkpoint_off;
 | 
						|
                        }
 | 
						|
                        else
 | 
						|
                        {
 | 
						|
                            sprite = &checkpoint_on;
 | 
						|
                        }
 | 
						|
                        glm::vec3 position = curve().relative(checkpoint["position"].get<float>());
 | 
						|
                        glm::vec2 delta = sb::math::angle_to_vector(
 | 
						|
                            checkpoint["angle"].get<float>(), configuration()("display", "checkpoint distance"));
 | 
						|
                        position += glm::vec3{delta.x, delta.y, 0.0f};
 | 
						|
                        sprite->translate(curve().wrap(position));
 | 
						|
                        sprite->draw(uniform.at("mvp"), view * rotation_matrix, zoom_projection,
 | 
						|
                                     uniform.at("texture_enabled"));
 | 
						|
                        index++;
 | 
						|
                    } }
 | 
						|
 | 
						|
                /* Draw enemies */
 | 
						|
                for (auto& enemy : enemies)
 | 
						|
                {
 | 
						|
                    enemy->draw(uniform.at("mvp"), view * rotation_matrix, zoom_projection, uniform.at("texture_enabled"),
 | 
						|
                                rotating_hue, uniform.at("color_addition"));
 | 
						|
                    if (!flash_animation.playing())
 | 
						|
                    {
 | 
						|
                        glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
                    } }
 | 
						|
 | 
						|
                /* Draw cake */
 | 
						|
                if (!replay_playback)
 | 
						|
                {
 | 
						|
                    character.draw(curve(), uniform.at("mvp"), view * rotation_matrix, zoom_projection,
 | 
						|
                                   uniform.at("texture_enabled"));
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    character.draw_at(uniform.at("mvp"), view * rotation_matrix, zoom_projection,
 | 
						|
                                      uniform.at("texture_enabled"), replay_playback->last_read().mirrored);
 | 
						|
                }
 | 
						|
 | 
						|
                /* Draw end screen coins */
 | 
						|
                if (end_screen())
 | 
						|
                {
 | 
						|
                    for (Flame& coin : ending_coins)
 | 
						|
                    {
 | 
						|
                        coin.draw(uniform.at("mvp"), view * rotation_matrix, zoom_projection, uniform.at("texture_enabled"),
 | 
						|
                                  rotating_hue, uniform.at("color_addition"));
 | 
						|
                        if (!flash_animation.playing())
 | 
						|
                        {
 | 
						|
                            glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
                        } } }
 | 
						|
 | 
						|
                /* Check if any buttons should be disabled */
 | 
						|
                bool profile_spinner_enabled = configuration()("progress", "max difficulty") > 0 && !resuming();
 | 
						|
                bool profile_spinner_visible = configuration()("progress", "max difficulty") > 0;
 | 
						|
                bool view_spinner_enabled = configuration()("progress", "max view") > 0;
 | 
						|
                button.at("level decrement").visible(level_select());
 | 
						|
                button.at("level decrement").enabled(level_select());
 | 
						|
                button.at("level increment").visible(level_select());
 | 
						|
                button.at("level increment").enabled(level_select());
 | 
						|
                button.at("profile decrement").visible(profile_spinner_enabled);
 | 
						|
                button.at("profile decrement").enabled(profile_spinner_enabled);
 | 
						|
                button.at("profile increment").visible(profile_spinner_enabled);
 | 
						|
                button.at("profile increment").enabled(profile_spinner_enabled);
 | 
						|
                button.at("view decrement").enabled(view_spinner_enabled);
 | 
						|
                button.at("view decrement").visible(view_spinner_enabled);
 | 
						|
                button.at("view increment").enabled(view_spinner_enabled);
 | 
						|
                button.at("view increment").visible(view_spinner_enabled);
 | 
						|
 | 
						|
                /* Get a reference to the challenge name and play mode menu status for later */
 | 
						|
                const nlohmann::json::string_t& challenge_name = configuration()(
 | 
						|
                    "challenge", challenge_index, "name").get_ref<const nlohmann::json::string_t&>();
 | 
						|
                bool play_mode_menu_active = (challenge_name != "OPTIONS" &&
 | 
						|
                                              challenge_name != "ACHIEVEMENTS" &&
 | 
						|
                                              challenge_name != "STATS" &&
 | 
						|
                                              challenge_name != "REPLAYS");
 | 
						|
 | 
						|
                /* Draw buttons. Don't include rotation matrix in view, so buttons will remain flat in the z-dimension.
 | 
						|
                 */
 | 
						|
                glm::mat4 label_transformation {0.0f};
 | 
						|
 | 
						|
                /* Draw title screen buttons */
 | 
						|
                if (level_index == 0)
 | 
						|
                {
 | 
						|
                    /* Play button - only draw on quest and arcade menus */
 | 
						|
                    if (challenge_name != "ACHIEVEMENTS"
 | 
						|
                        && challenge_name != "STATS"
 | 
						|
                        && challenge_name != "REPLAYS"
 | 
						|
                        && challenge_name != "OPTIONS")
 | 
						|
                    {
 | 
						|
                        if (configuration()("arcade", "arcade only") || is_selected_button("start"))
 | 
						|
                        {
 | 
						|
                            glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
                        }
 | 
						|
                        button.at("start").draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                        if (!flash_animation.playing())
 | 
						|
                        {
 | 
						|
                            glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
                        } }
 | 
						|
 | 
						|
                    /* Disable spinners if arcade prompt displayed */
 | 
						|
                    if (!configuration()("arcade", "arcade only"))
 | 
						|
                    {
 | 
						|
                        /* Collect names of buttons to draw. Always include challenge, but only include other spinners
 | 
						|
                         * when navigating a play mode menu */
 | 
						|
                        std::vector<std::string> names;
 | 
						|
                        if (play_mode_menu_active)
 | 
						|
                        {
 | 
						|
                            names = {"level select", "profile", "challenge", "view"};
 | 
						|
                        }
 | 
						|
                        else
 | 
						|
                        {
 | 
						|
                            names = {"challenge"};
 | 
						|
                        }
 | 
						|
 | 
						|
                        /* Draw spinner labels */
 | 
						|
                        for (const std::string& name : names)
 | 
						|
                        {
 | 
						|
                            if ((name != "profile" || profile_spinner_visible) &&
 | 
						|
                                (name != "view" || view_spinner_enabled))
 | 
						|
                            {
 | 
						|
                                label.at(name).texture(0).bind();
 | 
						|
                                label_transformation = projection * view * label.at(name).transformation();
 | 
						|
                                glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                                label.at(name).enable();
 | 
						|
                                glDrawArrays(GL_TRIANGLES, 0, label.at(name).attributes("position")->count());
 | 
						|
                            } }
 | 
						|
 | 
						|
                        /* If play mode menu is active, draw spinner buttons. */
 | 
						|
                        if (play_mode_menu_active)
 | 
						|
                        {
 | 
						|
                            names = {
 | 
						|
                                "level decrement", "level increment", "profile decrement", "profile increment",
 | 
						|
                                "challenge decrement", "challenge increment", "view decrement", "view increment"
 | 
						|
                            };
 | 
						|
                        }
 | 
						|
                        else
 | 
						|
                        {
 | 
						|
                            /* Always draw challenge spinner */
 | 
						|
                            names = {"challenge decrement", "challenge increment"};
 | 
						|
 | 
						|
                            /* If options menu is active, draw sub-menu */
 | 
						|
                            if (challenge_name == "OPTIONS")
 | 
						|
                            {
 | 
						|
                                names.push_back("bgm");
 | 
						|
                                names.push_back("sfx");
 | 
						|
                                if (configuration()("display", "fullscreen enabled"))
 | 
						|
                                {
 | 
						|
                                    names.push_back("fullscreen text");
 | 
						|
                                }
 | 
						|
                                if (configuration()("display", "exit enabled"))
 | 
						|
                                {
 | 
						|
                                    names.push_back("exit");
 | 
						|
                                } } }
 | 
						|
 | 
						|
                        /* Draw buttons */
 | 
						|
                        for (const std::string& name : names)
 | 
						|
                        {
 | 
						|
                            if (is_selected_button(name))
 | 
						|
                            {
 | 
						|
                                glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
                            }    
 | 
						|
                            button.at(name).draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                            if (!flash_animation.playing())
 | 
						|
                            {
 | 
						|
                                glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
                            } } }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        if (configuration()("arcade", "credits enabled"))
 | 
						|
                        {
 | 
						|
                            glUniform1i(uniform.at("texture_enabled"), true);
 | 
						|
                            label.at("credits available").texture(0).bind();
 | 
						|
                            label_transformation = projection * view * label.at("credits available").transformation();
 | 
						|
                            glUniformMatrix4fv(
 | 
						|
                                uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                            label.at("credits available").enable();
 | 
						|
                            glDrawArrays(
 | 
						|
                                GL_TRIANGLES, 0, label.at("credits available").attributes("position")->count());
 | 
						|
                        } } }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    /* Draw pause button */
 | 
						|
                    if (unpaused_timer)
 | 
						|
                    {
 | 
						|
                        if (!configuration()("arcade", "arcade only"))
 | 
						|
                        {
 | 
						|
                            button.at("pause").draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                        } }
 | 
						|
 | 
						|
                    /* Draw pause menu */
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        for (const std::string& name : {"resume", "reset", "fullscreen text", "bgm", "sfx"})
 | 
						|
                        {
 | 
						|
                            if (is_selected_button(name))
 | 
						|
                            {
 | 
						|
                                glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
                            }
 | 
						|
                            if (name != "fullscreen text" || configuration()("display", "fullscreen enabled"))
 | 
						|
                            {
 | 
						|
                                button.at(name).draw(
 | 
						|
                                    uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                            }
 | 
						|
                            if (!flash_animation.playing())
 | 
						|
                            {
 | 
						|
                                glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
                            } }
 | 
						|
 | 
						|
                        /* Draw playtester thanks */
 | 
						|
                        thanks.texture(0).bind();
 | 
						|
                        label_transformation = projection * view * thanks.transformation();
 | 
						|
                        glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                        thanks.enable();
 | 
						|
                        glDrawArrays(GL_TRIANGLES, 0, thanks.attributes("position")->count());
 | 
						|
 | 
						|
                        /* Draw version string */
 | 
						|
                        if (configuration()("diagnostic", "version") && label.at("version").texture(0).generated())
 | 
						|
                        {
 | 
						|
                            label_transformation = projection * view * label.at("version").transformation();
 | 
						|
                            label.at("version").texture(0).bind();
 | 
						|
                            glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                            label.at("version").enable();
 | 
						|
                            glDrawArrays(GL_TRIANGLES, 0, label.at("version").attributes("position")->count());
 | 
						|
                        } }
 | 
						|
 | 
						|
                    /* Draw name entry */
 | 
						|
                    if (static_cast<std::size_t>(level_index) == configuration()("levels").size() - 1 && arcade())
 | 
						|
                    {
 | 
						|
                        for (const std::string& button_name : {
 | 
						|
                                std::string("name 1"), std::string("name 2"), std::string("name 3"),
 | 
						|
                                "name " + std::to_string(name_entry_index + 1) + " increment",
 | 
						|
                                "name " + std::to_string(name_entry_index + 1) + " decrement"})
 | 
						|
                        {
 | 
						|
                            button.at(button_name).draw(uniform.at("mvp"), view, projection,
 | 
						|
                                                        uniform.at("texture_enabled"));
 | 
						|
                        } }
 | 
						|
 | 
						|
                    /* Draw the clock */
 | 
						|
                    float amount;
 | 
						|
                    if (arcade())
 | 
						|
                    {
 | 
						|
                        if (static_cast<std::size_t>(level_index) == configuration()("levels").size() - 1)
 | 
						|
                        {
 | 
						|
                            amount = arcade_time_remaining(arcade_score.time);
 | 
						|
                        }
 | 
						|
                        else
 | 
						|
                        {
 | 
						|
                            amount = arcade_time_remaining(extended_limit);
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        amount = run_timer.elapsed();
 | 
						|
                    }
 | 
						|
                    label.at("clock").content(format_clock(amount));
 | 
						|
                    label.at("clock").refresh();
 | 
						|
                    sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
                    glUniform1i(uniform.at("texture_enabled"), true);
 | 
						|
                    label.at("clock").texture(0).bind();
 | 
						|
                    label_transformation = projection * view * label.at("clock").transformation();
 | 
						|
                    glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                    label.at("clock").enable();
 | 
						|
                    glDrawArrays(GL_TRIANGLES, 0, label.at("clock").attributes("position")->count());
 | 
						|
 | 
						|
                    /* Draw replay end buttons */
 | 
						|
                    if (replay_playback && replay_playback->ended() && unpaused_timer)
 | 
						|
                    {
 | 
						|
                        for (const std::string& name : {"replay restart", "replay exit"})
 | 
						|
                        {
 | 
						|
                            if (is_selected_button(name))
 | 
						|
                            {
 | 
						|
                                glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
                            }
 | 
						|
                            button.at(name).draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                            if (!flash_animation.playing())
 | 
						|
                            {
 | 
						|
                                glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
                            } } } }
 | 
						|
 | 
						|
                /* Draw HUD */
 | 
						|
                if (level_index > 0 && static_cast<std::size_t>(level_index) < _configuration("levels").size() - 1)
 | 
						|
                {
 | 
						|
                    /* Draw the original text level indicator */
 | 
						|
                    if (configuration()("display", "level hud visible"))
 | 
						|
                    {
 | 
						|
                        std::stringstream level_indicator;
 | 
						|
                        level_indicator << std::setw(2) << std::setfill('0') << level_index << "/" << std::setw(2) <<
 | 
						|
                            _configuration("levels").size() - 2;
 | 
						|
                        label.at("level").content(level_indicator.str());
 | 
						|
                        label.at("level").refresh();
 | 
						|
                        label.at("level").texture(0).bind();
 | 
						|
                        label_transformation = projection * view * label.at("level").transformation();
 | 
						|
                        glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                        label.at("level").enable();
 | 
						|
                        glDrawArrays(GL_TRIANGLES, 0, label.at("level").attributes("position")->count());
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Draw the bank HUD */
 | 
						|
                    if (configuration()("coin ui", "visible") && !replay_playback)
 | 
						|
                    {
 | 
						|
                        for (const sb::Sprite& coin : bank_ui)
 | 
						|
                        {
 | 
						|
                            coin.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                        } } }
 | 
						|
 | 
						|
                /* Draw game over text */
 | 
						|
                if (game_over_animation.playing())
 | 
						|
                {
 | 
						|
                    label.at("game over").texture(0).bind();
 | 
						|
                    label_transformation = projection * view * label.at("game over").transformation();
 | 
						|
                    glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                    label.at("game over").enable();
 | 
						|
                    glDrawArrays(GL_TRIANGLES, 0, label.at("game over").attributes("position")->count());
 | 
						|
                }
 | 
						|
 | 
						|
                /* Draw idle warning in demo and arcade-only modes */
 | 
						|
                if (level_index > 0
 | 
						|
                    && !replay_playback
 | 
						|
                    && (configuration()("demo", "active") || configuration()("arcade", "arcade only")))
 | 
						|
                {
 | 
						|
                    std::string section = configuration()("demo", "active") ? "demo" : "arcade";
 | 
						|
                    if (idle_timer.elapsed() > configuration()(section, "countdown display timeout"))
 | 
						|
                    {
 | 
						|
                        std::stringstream idle_warning_message;
 | 
						|
                        int remaining = std::ceil(
 | 
						|
                            configuration()(section, "idle timeout").get<float>() - idle_timer.elapsed());
 | 
						|
                        idle_warning_message << configuration()("demo", "countdown message").get<std::string>() <<
 | 
						|
                            remaining;
 | 
						|
                        label.at("idle warning").content(idle_warning_message.str());
 | 
						|
                        label.at("idle warning").refresh();
 | 
						|
                        label.at("idle warning").texture(0).bind();
 | 
						|
                        label_transformation = projection * view * label.at("idle warning").transformation();
 | 
						|
                        glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                        label.at("idle warning").enable();
 | 
						|
                        glDrawArrays(GL_TRIANGLES, 0, label.at("idle warning").attributes("position")->count());
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                /* Draw demo message */
 | 
						|
                if (level_index == 0 && configuration()("demo", "active"))
 | 
						|
                {
 | 
						|
                    demo_message.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                }
 | 
						|
 | 
						|
                /* Draw arcade results */
 | 
						|
                if (static_cast<std::size_t>(level_index) == configuration()("levels").size() - 1 && arcade())
 | 
						|
                {
 | 
						|
                    for (const std::string name : {"arcade rank", "arcade distance"})
 | 
						|
                    {
 | 
						|
                        label.at(name).texture(0).bind();
 | 
						|
                        label_transformation = projection * view * label.at(name).transformation();
 | 
						|
                        glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                        label.at(name).enable();
 | 
						|
                        glDrawArrays(GL_TRIANGLES, 0, label.at(name).attributes("position")->count());
 | 
						|
                    } }
 | 
						|
 | 
						|
                /* Draw scoreboard, QR, quest best, auto save icon, achievements, stats, version string, and
 | 
						|
                 * confirmation alert on title screen */
 | 
						|
                if (level_index == 0)
 | 
						|
                {
 | 
						|
                    /* On play modes, only draw scoreboard if arcade mode is selected. Otherwise, draw the quest best
 | 
						|
                     * indicator. */
 | 
						|
                    if (arcade())
 | 
						|
                    {
 | 
						|
                        scoreboard.texture(0).bind();
 | 
						|
                        label_transformation = projection * view * scoreboard.transformation();
 | 
						|
                        glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                        scoreboard.enable();
 | 
						|
                        glDrawArrays(GL_TRIANGLES, 0, scoreboard.attributes("position")->count());
 | 
						|
                    }
 | 
						|
                    else if (play_mode_menu_active && quest() && configuration()("progress", "quest best") > 0.0f)
 | 
						|
                    {
 | 
						|
                        label.at("quest best").texture(0).bind();
 | 
						|
                        label_transformation = projection * view * label.at("quest best").transformation();
 | 
						|
                        glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                        label.at("quest best").enable();
 | 
						|
                        glDrawArrays(GL_TRIANGLES, 0, label.at("quest best").attributes("position")->count());
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Draw progress display menus */
 | 
						|
                    if (challenge_name == "ACHIEVEMENTS")
 | 
						|
                    {
 | 
						|
                        draw_achievements(view, projection, uniform);
 | 
						|
                    }
 | 
						|
                    else if (challenge_name == "STATS")
 | 
						|
                    {
 | 
						|
                        draw_stats(view, projection, uniform);
 | 
						|
                    }
 | 
						|
                    else if (challenge_name == "REPLAYS")
 | 
						|
                    {
 | 
						|
                        draw_replays_menu(view, projection, uniform);
 | 
						|
                    }
 | 
						|
            
 | 
						|
                    /* Draw QR. Only draw auto save if QR is not displayed. */
 | 
						|
                    if (configuration()("display", "qr display"))
 | 
						|
                    {
 | 
						|
                        if (configuration()("display", "qr background display"))
 | 
						|
                        {
 | 
						|
                            qr_code_bg.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                        }
 | 
						|
                        qr_code.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        auto_save.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Draw Steam badge */
 | 
						|
                    if (configuration()("display", "steam button visible"))
 | 
						|
                    {
 | 
						|
                        button.at("steam").draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Draw dank.game badge */
 | 
						|
                    if (configuration()("display", "dank logo visible"))
 | 
						|
                    {
 | 
						|
                        button.at("dank").draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                    }
 | 
						|
 | 
						|
                    /* Draw version string */
 | 
						|
                    if (configuration()("diagnostic", "version") && label.at("version").texture(0).generated())
 | 
						|
                    {
 | 
						|
                        label_transformation = projection * view * label.at("version").transformation();
 | 
						|
                        label.at("version").texture(0).bind();
 | 
						|
                        glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                        label.at("version").enable();
 | 
						|
                        glDrawArrays(GL_TRIANGLES, 0, label.at("version").attributes("position")->count());
 | 
						|
                    } 
 | 
						|
                }
 | 
						|
 | 
						|
                /* Draw end screen messages */
 | 
						|
                if (static_cast<std::size_t>(level_index) == configuration()("levels").size() - 1 &&
 | 
						|
                    !configuration()("arcade", "arcade only"))
 | 
						|
                {
 | 
						|
                    float y = configuration()("ending", "messages y").get<float>();
 | 
						|
                    for (std::size_t message_ii = 0; message_ii < ending_messages.size(); message_ii++)
 | 
						|
                    {
 | 
						|
                        sb::Text& message = ending_messages[message_ii];
 | 
						|
                        message.untransform();
 | 
						|
                        message.translate({0.0f, y, 0.0f});
 | 
						|
                        message.scale(configuration()("ending", "messages scale"));
 | 
						|
                        message.texture(0).bind();
 | 
						|
                        label_transformation = projection * view * message.transformation();
 | 
						|
                        glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label_transformation[0][0]);
 | 
						|
                        message.enable();
 | 
						|
                        glDrawArrays(GL_TRIANGLES, 0, message.attributes("position")->count());
 | 
						|
                        if (message_ii == 0)
 | 
						|
                        {
 | 
						|
                            y += configuration()("ending", "messages margin").get<float>();
 | 
						|
                        }
 | 
						|
                        y += configuration()("ending", "messages step").get<float>();
 | 
						|
                    } }
 | 
						|
 | 
						|
                /* Draw achievements pop up if active */
 | 
						|
                if (achievements_pop_up_animation.playing())
 | 
						|
                {
 | 
						|
                    achievements_pop_up_sprite.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
                }
 | 
						|
 | 
						|
                /* Draw confirmation alert */
 | 
						|
                if (confirming_new_quest || confirming_new_arcade)
 | 
						|
                {
 | 
						|
                    draw_confirmation_alert(view, projection, uniform);
 | 
						|
                } } }
 | 
						|
        
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* Draw splash screen */
 | 
						|
            sb::Plane::color->bind("vertex_color", shader_program);
 | 
						|
            sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
            splash[splash_index].sprite.bind();
 | 
						|
            splash[splash_index].sprite.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
        } }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        /* Draw the play button and background if it is enabled and hasn't been pressed yet */
 | 
						|
        sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
        sb::Plane::color->bind("vertex_color", shader_program);
 | 
						|
        play_button_bg.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
        button.at("play").draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
    }
 | 
						|
 | 
						|
    /* Always draw the volume and fullscreen buttons if enabled unless it's arcade only mode */
 | 
						|
    if (!configuration()("arcade", "arcade only"))
 | 
						|
    {
 | 
						|
        for (const std::string name : {"volume", "fullscreen"})
 | 
						|
        {
 | 
						|
            if (name != "fullscreen" || configuration()("display", "fullscreen enabled"))
 | 
						|
            {
 | 
						|
                sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
                sb::Plane::color->bind("vertex_color", shader_program);
 | 
						|
                button.at(name).draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
            } } }
 | 
						|
 | 
						|
    /* Update FPS indicator display to the current FPS count and draw. */
 | 
						|
    if (configuration()("display", "fps"))
 | 
						|
    {
 | 
						|
        if (current_frames_per_second != previous_frames_per_second)
 | 
						|
        {
 | 
						|
            std::string padded = sb::pad(current_frames_per_second, 2);
 | 
						|
            label.at("fps").content(padded);
 | 
						|
            label.at("fps").refresh();
 | 
						|
            previous_frames_per_second = current_frames_per_second;
 | 
						|
        }
 | 
						|
        if (label.at("fps").texture(0).generated())
 | 
						|
        {
 | 
						|
            /* Draw FPS indicator */
 | 
						|
            sb::Plane::color->bind("vertex_color", shader_program);
 | 
						|
            sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
            glUniform1i(uniform.at("texture_enabled"), true);
 | 
						|
            label.at("fps").texture(0).bind();
 | 
						|
            glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &label.at("fps").transformation()[0][0]);
 | 
						|
            label.at("fps").enable();
 | 
						|
            glDrawArrays(GL_TRIANGLES, 0, label.at("fps").attributes("position")->count());
 | 
						|
        } }
 | 
						|
 | 
						|
    /* Update display */
 | 
						|
    SDL_GL_SwapWindow(window().get());
 | 
						|
    sb::Log::gl_errors("at end of update");
 | 
						|
}
 | 
						|
 | 
						|
int Cakefoot::replay_button_index_to_level(int button_index) const
 | 
						|
{
 | 
						|
    return configuration()("replay", "menu", "start level").get<std::size_t>() + button_index;
 | 
						|
}
 | 
						|
 | 
						|
int Cakefoot::level_index_to_replay_button(int level_index) const
 | 
						|
{
 | 
						|
    return level_index - configuration()("replay", "menu", "start level").get<std::size_t>();
 | 
						|
}
 | 
						|
 | 
						|
std::vector<cakefoot::Replay> Cakefoot::replays(int level) const
 | 
						|
{
 | 
						|
    std::vector<cakefoot::Replay> replays;
 | 
						|
    if (fs::exists(configuration()("replay", "directory")))
 | 
						|
    {
 | 
						|
        std::ostringstream pattern;
 | 
						|
        pattern << std::setfill('0') << std::setw(2) << level << "_[0-9]+_.+\\.json";
 | 
						|
        for (const fs::path& path : sb::glob(pattern.str(), configuration()("replay", "directory")))
 | 
						|
        {
 | 
						|
            try
 | 
						|
            {
 | 
						|
                cakefoot::Replay replay;
 | 
						|
                replay.load(path);
 | 
						|
                replays.push_back(replay);
 | 
						|
            }
 | 
						|
            catch (nlohmann::json::parse_error error)
 | 
						|
            {
 | 
						|
                /* Ignore unreadable replays */
 | 
						|
                sb::Log::Multi(sb::Log::WARN) << "Replay file is unreadable at " << path << ": " << error.what() <<
 | 
						|
                    sb::Log::end;
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
    return replays;
 | 
						|
}
 | 
						|
 | 
						|
cakefoot::Replay Cakefoot::fastest_replay(int level) const
 | 
						|
{
 | 
						|
    cakefoot::Replay fastest;
 | 
						|
    for (cakefoot::Replay replay : replays(level))
 | 
						|
    {
 | 
						|
        if (fastest.empty() || replay.length() < fastest.length())
 | 
						|
        {
 | 
						|
            fastest = replay;
 | 
						|
        }
 | 
						|
    }
 | 
						|
    return fastest;
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::store_replay_recording(cakefoot::Replay& replay, int level)
 | 
						|
{
 | 
						|
    if (replay.length() <= configuration()("replay", "max length") && level >= configuration()("replay", "min level"))
 | 
						|
    {
 | 
						|
        bool save_replay = false;
 | 
						|
        std::vector<cakefoot::Replay> existing_replays { replays(level) };
 | 
						|
 | 
						|
        /* If maximum number of replays hasn't been reached for the given level, save the replay and exit. */
 | 
						|
        if (existing_replays.size() < configuration()("replay", "max files per level"))
 | 
						|
        {
 | 
						|
            save_replay = true;
 | 
						|
        }
 | 
						|
 | 
						|
        /* If the replacement method is oldest, remove the oldest replay and save the current one. */
 | 
						|
        else if (configuration()("replay", "replace") == "oldest")
 | 
						|
        {
 | 
						|
            save_replay = true;
 | 
						|
 | 
						|
            /* Replays are sorted lexicographically by time because their paths contain the date string. Therefore, the
 | 
						|
             * first replay in the list is the oldest replay. */
 | 
						|
            existing_replays.front().remove();
 | 
						|
            sb::Log::Multi() << "Removed oldest existing replay for level " << level << sb::Log::end;
 | 
						|
        }
 | 
						|
 | 
						|
        /* The default replacement method is slowest. Determine the slowest replay among existing replays and the
 | 
						|
         * incoming one. If the incoming one is not the slowest, delete the slowest replay before saving the new one. */
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* Track the time and the path of the slowest replay */
 | 
						|
            cakefoot::Replay slowest;
 | 
						|
 | 
						|
            /* Find the slowest replay for the given level */
 | 
						|
            for (const cakefoot::Replay& existing : existing_replays)
 | 
						|
            {
 | 
						|
                if (slowest.empty() || existing.length() >= slowest.length())
 | 
						|
                {
 | 
						|
                    slowest = existing;
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            /* Only save replay if it is faster than the slowest existing replay. */
 | 
						|
            if (replay.length() < slowest.length())
 | 
						|
            {
 | 
						|
                save_replay = true;
 | 
						|
                slowest.remove();
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                sb::Log::Line() << "Replay discarded because existing replays are faster";
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Write JSON to disk */
 | 
						|
        if (save_replay)
 | 
						|
        {
 | 
						|
            /* Include rank in the metadata */
 | 
						|
            replay.save(level, {{"rank", profile_index}}, configuration()("replay", "directory"), stat_progress.id());
 | 
						|
 | 
						|
            /* Check if the new replay is the new fastest and set a flag if so. */
 | 
						|
            cakefoot::Replay fastest { fastest_replay(level) };
 | 
						|
            fastest_replay_added = !fastest.empty() && fastest.id() == replay.id();
 | 
						|
 | 
						|
#if defined(HTTP_ENABLED)
 | 
						|
            /* Upload the replay to the configured receiver URL */
 | 
						|
            if (http.initialized() && !configuration()("replay", "receiver").value("url", "").empty())
 | 
						|
            {
 | 
						|
                http.post_analytics(
 | 
						|
                    configuration()("replay", "receiver", "url"),
 | 
						|
                    replay.json(),
 | 
						|
                    configuration()("session", "title"),
 | 
						|
                    cakefoot::version,
 | 
						|
                    configuration()("session").value("platform", ""),
 | 
						|
                    stat_progress.id(),
 | 
						|
                    configuration()("session", "receiver").value("authorization", ""));
 | 
						|
            }
 | 
						|
#endif
 | 
						|
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else
 | 
						|
    {
 | 
						|
        sb::Log::Line() << "Replay discarded because it either exceeds maximum length or the level is too low";
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::launch_replay(const cakefoot::Replay& replay)
 | 
						|
{
 | 
						|
    if (!replay.empty())
 | 
						|
    {
 | 
						|
        sb::Log::Multi() << "Loading replay for level " << replay.metadata().at("level") << sb::Log::end;
 | 
						|
        load_level(replay.metadata().at("level"), true);
 | 
						|
 | 
						|
        /* Load and start playing the replay */
 | 
						|
        replay_playback = replay;
 | 
						|
        replay_playback_animation.frame_length(configuration()("replay", "frame length"));
 | 
						|
        replay_playback_animation.reset();
 | 
						|
        replay_playback_animation.play();
 | 
						|
 | 
						|
        /* Temporarily set the character profile to the rank stored in the replay metadata. This will automatically
 | 
						|
         * reset to the profile currently selected (the profile stored to progress) when the title screen loads after
 | 
						|
         * the replay is exited. */
 | 
						|
        const nlohmann::json& metadata = replay.metadata();
 | 
						|
        profile_index = 0;
 | 
						|
        if (!metadata.empty() && metadata.contains("rank"))
 | 
						|
        {
 | 
						|
            profile_index = metadata.at("rank");
 | 
						|
        }
 | 
						|
        character.profile(configuration()("character", "profile", profile_index, "name"));
 | 
						|
 | 
						|
        /* Jump to a random position in the BGM */
 | 
						|
        bgm.jump(sb::math::random::integer(0, configuration()("replay", "bgm jump range")));
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
cakefoot::Replay Cakefoot::random_attract_mode_replay(int omit_level) const
 | 
						|
{
 | 
						|
    cakefoot::Replay replay;
 | 
						|
 | 
						|
    /* Build pool of replays to choose from */
 | 
						|
    std::vector<fs::path> pool;
 | 
						|
    for (fs::path directory : {configuration()("replay", "directory"), configuration()("attract", "replays")})
 | 
						|
    {
 | 
						|
        if (fs::exists(directory))
 | 
						|
        {
 | 
						|
            std::ostringstream pattern;
 | 
						|
            pattern << "[0-9][0-9]_[0-9]+_.+\\.json";
 | 
						|
            for (const fs::path& path : sb::glob(pattern.str(), directory))
 | 
						|
            {
 | 
						|
                pool.push_back(path);
 | 
						|
            } } }
 | 
						|
 | 
						|
    /* Choose a random replay out of the pool until a non-omitted level is chosen. */
 | 
						|
    if (pool.size() > 0)
 | 
						|
    {
 | 
						|
        int tries = 0;
 | 
						|
        do
 | 
						|
        {
 | 
						|
            int choice = sb::math::random::integer(0, pool.size() - 1);
 | 
						|
            try
 | 
						|
            {
 | 
						|
                replay.load(pool.at(choice));
 | 
						|
            }
 | 
						|
            catch (nlohmann::json::parse_error error)
 | 
						|
            {
 | 
						|
                /* Ignore unreadable replays */
 | 
						|
                sb::Log::Multi(sb::Log::WARN) << "Replay file is unreadable at " << pool.at(choice) << ": " <<
 | 
						|
                    error.what() << sb::Log::end;
 | 
						|
            }
 | 
						|
        } while (!replay.metadata().empty()
 | 
						|
                 && replay.metadata().contains("level")
 | 
						|
                 && replay.metadata().at("level") == omit_level
 | 
						|
                 && tries++ < 10);
 | 
						|
    }
 | 
						|
 | 
						|
    return replay;
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_achievements_menu()
 | 
						|
{
 | 
						|
    /* Clear any loaded graphics */
 | 
						|
    achievements_text_sprites.clear();
 | 
						|
 | 
						|
    /* All style parameters for the menu from the config */
 | 
						|
    const nlohmann::json& style = configuration()("achievements menu");
 | 
						|
 | 
						|
    /* Create a background to draw the achievements over */
 | 
						|
    achievements_background.scale(style.at("background").at("scale"));
 | 
						|
    achievements_background.translate(style.at("background").at("position").get<glm::vec2>());
 | 
						|
    achievements_background_color.percent(style.at("background").at("color").get<glm::fvec4>());
 | 
						|
 | 
						|
    /* Pull the text styles */
 | 
						|
    sb::Color text_color;
 | 
						|
    text_color.percent(style.at("text").at("color").get<glm::fvec4>());
 | 
						|
    sb::Color unlocked_color;
 | 
						|
    unlocked_color.percent(style.at("unlocked").get<glm::fvec4>());
 | 
						|
    sb::Color locked_color;
 | 
						|
    locked_color.percent(style.at("locked").get<glm::fvec4>());
 | 
						|
 | 
						|
    /* Create achievement text sprites in a grid */
 | 
						|
    int col = 0;
 | 
						|
    int row = 0;
 | 
						|
    for (const sb::progress::Achievement& achievement : achievements)
 | 
						|
    {
 | 
						|
        const sb::Color& text_background {
 | 
						|
            stat_progress.achievement_unlocked(achievement) ? unlocked_color : locked_color
 | 
						|
        };
 | 
						|
        sb::Text text {
 | 
						|
            fonts.at("narrow progress menu"),
 | 
						|
            achievement.description(),
 | 
						|
            text_color,
 | 
						|
            text_background,
 | 
						|
            style.at("text").at("dimensions").get<glm::vec2>()
 | 
						|
        };
 | 
						|
        text.wrap(style.at("text").at("wrap"));
 | 
						|
        text.refresh();
 | 
						|
        sb::Sprite text_sprite {text};
 | 
						|
        text_sprite.scale(style.at("text").at("scale"));
 | 
						|
        const nlohmann::json& step = style.at("text").at("step");
 | 
						|
        const nlohmann::json& start = style.at("text").at("start");
 | 
						|
        text_sprite.translate({
 | 
						|
                col * step.at(0).get<float>() + start.at(0).get<float>(),
 | 
						|
                row++ * step.at(1).get<float>() + start.at(1).get<float>()});
 | 
						|
        if (row >= style.at("rows"))
 | 
						|
        {
 | 
						|
            row = 0;
 | 
						|
            col++;
 | 
						|
        }
 | 
						|
        achievements_text_sprites.push_back(text_sprite);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::draw_achievements(
 | 
						|
    const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform) const
 | 
						|
{
 | 
						|
    /* Draw a background which the individual achievements will be drawn over */
 | 
						|
    glUniform4fv(uniform.at("color_addition"), 1, &achievements_background_color.normal()[0]);
 | 
						|
    achievements_background.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
    glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
 | 
						|
    /* Draw individual achievements in a grid */
 | 
						|
    for (const sb::Sprite& sprite : achievements_text_sprites)
 | 
						|
    {
 | 
						|
        sprite.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_replays_menu()
 | 
						|
{
 | 
						|
    /* Clear any loaded graphics */
 | 
						|
    replay_buttons.clear();
 | 
						|
 | 
						|
    /* All style parameters for the menu from the config */
 | 
						|
    const nlohmann::json& style = configuration()("replay", "menu");
 | 
						|
 | 
						|
    /* Create a background to draw the achievements over */
 | 
						|
    replays_background.scale(style.at("background").at("scale"));
 | 
						|
    replays_background.translate(style.at("background").at("position").get<glm::vec2>());
 | 
						|
    replays_background_color.percent(style.at("background").at("color").get<glm::fvec4>());
 | 
						|
 | 
						|
    /* Pull the text styles */
 | 
						|
    sb::Color text_color;
 | 
						|
    text_color.percent(style.at("text").at("color").get<glm::fvec4>());
 | 
						|
    sb::Color unlocked_background;
 | 
						|
    unlocked_background.percent(style.at("unlocked background").get<glm::fvec4>());
 | 
						|
    sb::Color locked_background;
 | 
						|
    locked_background.percent(style.at("locked background").get<glm::fvec4>());
 | 
						|
 | 
						|
    /* Create replay buttons in a grid */
 | 
						|
    int col = 0;
 | 
						|
    int row = 0;
 | 
						|
    for (std::size_t replay_index = style.at("start level");
 | 
						|
         replay_index < configuration()("levels").size() - 1;
 | 
						|
         replay_index++)
 | 
						|
    {
 | 
						|
        /* Get fastest replay for level, if possible */
 | 
						|
        cakefoot::Replay fastest { fastest_replay(replay_index) };
 | 
						|
 | 
						|
        /* Build text for the replay button based on whether it's unlocked or not */
 | 
						|
        std::string message;
 | 
						|
        std::ostringstream seconds;
 | 
						|
        if (fastest.empty())
 | 
						|
        {
 | 
						|
            message = style.at("locked message");
 | 
						|
            seconds << configuration()("replay", "max length");
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            message = style.at("unlocked message");
 | 
						|
            seconds << std::setprecision(style.at("float precision")) << fastest.length();
 | 
						|
        };
 | 
						|
        std::ostringstream level;
 | 
						|
        level << replay_index;
 | 
						|
        message.replace(message.find("{level}"), std::string("{level}").size(), level.str());
 | 
						|
        message.replace(message.find("{seconds}"), std::string("{seconds}").size(), seconds.str());
 | 
						|
 | 
						|
        /* Generate text plane with generated message */
 | 
						|
        sb::Text text {
 | 
						|
            fonts.at(style.at("font")),
 | 
						|
            message,
 | 
						|
            text_color,
 | 
						|
            fastest.empty() ? locked_background : unlocked_background,
 | 
						|
            style.at("text").at("dimensions").get<glm::vec2>()
 | 
						|
        };
 | 
						|
        text.wrap(style.at("text").at("wrap"));
 | 
						|
        text.refresh();
 | 
						|
 | 
						|
        /* Create a button for the replay, then translate and scale it. Enable or disable based on existence of replay
 | 
						|
         * to launch. Do that now, so that the replays don't have to be opened and parsed when checking whether the
 | 
						|
         * button is usable or not. */
 | 
						|
        sb::Pad<> pad { text };
 | 
						|
        const nlohmann::json& step = style.at("text").at("step");
 | 
						|
        const nlohmann::json& start = style.at("text").at("start");
 | 
						|
        pad.translate({
 | 
						|
                col * step.at(0).get<float>() + start.at(0).get<float>(),
 | 
						|
                row++ * step.at(1).get<float>() + start.at(1).get<float>()});
 | 
						|
        pad.enabled(!fastest.empty());
 | 
						|
        if (row >= style.at("rows"))
 | 
						|
        {
 | 
						|
            row = 0;
 | 
						|
            col++;
 | 
						|
        }
 | 
						|
        pad.scale(style.at("text").at("scale").at(0),
 | 
						|
                  style.at("text").at("scale").at(1).get<float>() / style.at("text").at("scale").at(0).get<float>());
 | 
						|
 | 
						|
        /* Commit the replay button */
 | 
						|
        replay_buttons.push_back(pad);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::draw_replays_menu(
 | 
						|
    const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform)
 | 
						|
{
 | 
						|
    /* Draw a background which the individual achievements will be drawn over */
 | 
						|
    glUniform4fv(uniform.at("color_addition"), 1, &replays_background_color.normal()[0]);
 | 
						|
    replays_background.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
    glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
 | 
						|
    /* Draw individual achievements in a grid */
 | 
						|
    for (sb::Pad<>& pad : replay_buttons)
 | 
						|
    {
 | 
						|
        if (is_selected_button(pad))
 | 
						|
        {
 | 
						|
            glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
        }
 | 
						|
        pad.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
        glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_stats_menu()
 | 
						|
{
 | 
						|
    /* All style parameters for the menu from the config */
 | 
						|
    const nlohmann::json& style = configuration()("stats menu");
 | 
						|
 | 
						|
    /* Build a chart using monospace font and string formatting */
 | 
						|
    std::ostringstream formatted;
 | 
						|
    int count = 0;
 | 
						|
    for (const sb::progress::Stat& stat : stats)
 | 
						|
    {
 | 
						|
        formatted << std::setw(style.at("columns").at(0)) << stat.name() << ":" << std::setw(style.at("columns").at(1));
 | 
						|
        if (stat.type() == sb::progress::Stat::FLOAT)
 | 
						|
        {
 | 
						|
            formatted << std::setprecision(style.at("precision")) << std::fixed <<
 | 
						|
                stat_progress.stat_default(stat, 0.0f, stats) << std::setprecision(0);
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            formatted << stat_progress.stat_default(stat, 0, stats);
 | 
						|
        }
 | 
						|
        formatted << std::setw(style.at("columns").at(2)) << " ";
 | 
						|
        if (++count % 2 == 0)
 | 
						|
        {
 | 
						|
            formatted << "\n";
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Create a sprite containing all the stats as a single text texture */
 | 
						|
    sb::Color text_color;
 | 
						|
    text_color.percent(style.at("foreground").get<glm::fvec4>());
 | 
						|
    sb::Color background;
 | 
						|
    background.percent(style.at("background").get<glm::fvec4>());
 | 
						|
    sb::Text text {
 | 
						|
        fonts.at(style.at("font")),
 | 
						|
        formatted.str(),
 | 
						|
        text_color,
 | 
						|
        background
 | 
						|
    };
 | 
						|
    text.wrap(0);
 | 
						|
    text.refresh();
 | 
						|
    stats_sprite = sb::Sprite(text);
 | 
						|
    stats_sprite.scale(style.at("scale").get<glm::vec2>());
 | 
						|
    stats_sprite.translate(style.at("position").get<glm::vec2>());
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::draw_stats(
 | 
						|
    const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform) const
 | 
						|
{
 | 
						|
    /* Draw a single text texture */
 | 
						|
    stats_sprite.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_achievements_pop_up()
 | 
						|
{
 | 
						|
    const nlohmann::json& style = configuration()("achievements menu", "pop up");
 | 
						|
 | 
						|
    /* Add a heading line unless there are already too many lines */
 | 
						|
    std::string text = achievements_pop_up_text;
 | 
						|
    if (std::count(achievements_pop_up_text.begin(), achievements_pop_up_text.end(), '\n') <
 | 
						|
        style.at("limit").get<int>())
 | 
						|
    {
 | 
						|
        text = style.at("heading").get<std::string>() + "\n" + text;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Create sprite */
 | 
						|
    sb::Color text_color;
 | 
						|
    text_color.percent(style.at("foreground").get<glm::fvec4>());
 | 
						|
    sb::Color background;
 | 
						|
    background.percent(style.at("background").get<glm::fvec4>());
 | 
						|
    sb::Text pop_up {
 | 
						|
        fonts.at(style.at("font")),
 | 
						|
        text,
 | 
						|
        text_color,
 | 
						|
        background,
 | 
						|
        style.at("dimensions").get<glm::vec2>()
 | 
						|
    };
 | 
						|
    pop_up.wrap(0);
 | 
						|
    pop_up.refresh();
 | 
						|
    achievements_pop_up_sprite = sb::Sprite(pop_up);
 | 
						|
    achievements_pop_up_sprite.scale(style.at("scale").get<glm::vec2>());
 | 
						|
    achievements_pop_up_sprite.translate(style.at("position").get<glm::vec2>());
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_confirmation_alert()
 | 
						|
{
 | 
						|
    /* Reusable style variables */
 | 
						|
    const nlohmann::json& style = configuration()().at("confirmation");
 | 
						|
    sb::Color foreground;
 | 
						|
    sb::Color background;
 | 
						|
 | 
						|
    /* Create sprite */
 | 
						|
    foreground.percent(style.at("foreground").get<glm::fvec4>());
 | 
						|
    background.percent(style.at("background").get<glm::fvec4>());
 | 
						|
    sb::Text label {
 | 
						|
        fonts.at(style.at("font")),
 | 
						|
        style.at("message"),
 | 
						|
        foreground,
 | 
						|
        background
 | 
						|
    };
 | 
						|
    label.refresh();
 | 
						|
    confirmation_alert_label = sb::Sprite(label);
 | 
						|
    confirmation_alert_label.translate(style.at("translation").get<glm::vec2>());
 | 
						|
 | 
						|
    /* Use the pixel aspect ratio of the text to scale the width at the same ratio as the height. */
 | 
						|
    float height { style.at("scale") };
 | 
						|
    glm::fvec2 dimensions { label.dimensions() };
 | 
						|
    float width { height * (dimensions.x / dimensions.y) };
 | 
						|
    confirmation_alert_label.scale({width, height});
 | 
						|
 | 
						|
    /* Create buttons */
 | 
						|
    for (const std::string name : {"confirm", "cancel"})
 | 
						|
    {
 | 
						|
        float aspect_ratio;
 | 
						|
        const nlohmann::json& button_style = configuration()("confirmation", name);
 | 
						|
 | 
						|
        /* Create a text plane */
 | 
						|
        glm::ivec2 dimensions { button_style.at("dimensions") };
 | 
						|
        foreground.percent(button_style.at("foreground").get<glm::fvec4>());
 | 
						|
        background.percent(button_style.at("background").get<glm::fvec4>());
 | 
						|
        sb::Text message
 | 
						|
            {
 | 
						|
                fonts.at(button_style.at("font")),
 | 
						|
                button_style.at("text"),
 | 
						|
                foreground,
 | 
						|
                background,
 | 
						|
                dimensions
 | 
						|
            };
 | 
						|
        message.refresh();
 | 
						|
        aspect_ratio = float(dimensions.y) / dimensions.x;
 | 
						|
 | 
						|
        /* Create a button */
 | 
						|
        sb::Pad<> button { message, button_style.at("translation"), button_style.at("scale"), aspect_ratio };
 | 
						|
        if (name == "confirm")
 | 
						|
        {
 | 
						|
            confirmation_confirm_button = button;
 | 
						|
        }
 | 
						|
        else if (name == "cancel")
 | 
						|
        {
 | 
						|
            confirmation_cancel_button = button;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /* Forward the press to the start button which will dismiss the confirmation alert */
 | 
						|
    confirmation_confirm_button.on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        if (confirming_new_quest || confirming_new_arcade) {
 | 
						|
            button.at("start").press();
 | 
						|
        } });
 | 
						|
 | 
						|
    /* Set cancel button to dismiss alert without pressing the start button */
 | 
						|
    confirmation_cancel_button.on_state_change([&]([[maybe_unused]] bool state){
 | 
						|
        if (confirming_new_quest || confirming_new_arcade) {
 | 
						|
            confirming_new_quest = false;
 | 
						|
            confirming_new_arcade = false;
 | 
						|
        } });
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::draw_confirmation_alert(
 | 
						|
    const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform)
 | 
						|
{
 | 
						|
    confirmation_alert_label.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
 | 
						|
 | 
						|
    /* Draw confirm button with highlighting if currently selected */
 | 
						|
    if (is_selected_button(confirmation_confirm_button))
 | 
						|
    {
 | 
						|
        glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
    }
 | 
						|
    confirmation_confirm_button.draw(uniform.at("mvp"), view, projection);
 | 
						|
    glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
 | 
						|
    /* Draw cancel button with highlighting if currently selected */
 | 
						|
    if (is_selected_button(confirmation_cancel_button))
 | 
						|
    {
 | 
						|
        glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
    }
 | 
						|
    confirmation_cancel_button.draw(uniform.at("mvp"), view, projection);
 | 
						|
    glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::load_operator_menu(bool preserve)
 | 
						|
{
 | 
						|
    /* Create label sprites for the UI */
 | 
						|
    operator_menu_labels.clear();
 | 
						|
    for (const auto& [name, label] : configuration()("operator", "labels").items())
 | 
						|
    {
 | 
						|
        /* Show the unsaved changes status message */
 | 
						|
        std::string content { label.at("text") };
 | 
						|
        if (name == "status")
 | 
						|
        {
 | 
						|
            std::string status;
 | 
						|
            if (operator_menu_confirming)
 | 
						|
            {
 | 
						|
                status = configuration()("operator", "confirmation message");
 | 
						|
            }
 | 
						|
            else if (operator_menu_edited)
 | 
						|
            {
 | 
						|
                status = configuration()("operator", "unsaved message");
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                status = configuration()("operator", "saved message");
 | 
						|
            }
 | 
						|
            content.replace(content.find("{status}"), std::string("{status}").size(), status);
 | 
						|
        }
 | 
						|
 | 
						|
        /* Create a text plane with word wrapping enabled */
 | 
						|
        sb::Text text { fonts.at(configuration()("operator", "font")), content,
 | 
						|
                        configuration()("operator", "foreground").get<glm::fvec4>(),
 | 
						|
                        configuration()("operator", "background").get<glm::fvec4>() };
 | 
						|
        text.wrap(configuration()("operator", "wrap"));
 | 
						|
        text.refresh();
 | 
						|
 | 
						|
        /* Create a sprite object using the text plane. */
 | 
						|
        sb::Sprite sprite {text};
 | 
						|
        if (label.contains("scale"))
 | 
						|
        {
 | 
						|
            /* The multi-line intro needs custom scaling because SDL's function for measuring multi-line text isn't
 | 
						|
             * introduced until SDL3. */
 | 
						|
            sprite.scale(label.at("scale").get<glm::fvec2>());
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* Use the pixel aspect ratio of the text to scale the width at the same ratio as the height. */
 | 
						|
            float height {configuration()("operator", "scale")};
 | 
						|
            glm::fvec2 dimensions {text.dimensions()};
 | 
						|
            float width {height * (dimensions.x / dimensions.y)};
 | 
						|
            sprite.scale({width, height});
 | 
						|
        }
 | 
						|
        sprite.translate(label.at("translation").get<glm::fvec2>());
 | 
						|
        operator_menu_labels[name] = sprite;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Create buttons. Build a new map and replace the old one afterward, if it exists. */
 | 
						|
    std::map<std::string, sb::Pad<>> buttons;
 | 
						|
    for (const auto& [name, button] : configuration()("operator", "buttons").items())
 | 
						|
    {
 | 
						|
        sb::Plane plane;
 | 
						|
        float aspect_ratio;
 | 
						|
        std::optional<bool> current_state;
 | 
						|
        if (name == "enable credits")
 | 
						|
        {
 | 
						|
            /* Save current state if applicable, or use the config value. */
 | 
						|
            if (preserve && operator_menu_buttons.count("enable credits") > 0)
 | 
						|
            {
 | 
						|
                current_state = operator_menu_buttons.at("enable credits").pressed();
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                current_state = configuration()("arcade", "credits enabled");
 | 
						|
            }
 | 
						|
 | 
						|
            /* Create a plane and add two textures to it, one for each state of the checkbox. */
 | 
						|
            sb::Texture unchecked;
 | 
						|
            unchecked.load(configuration()("texture", button.at("unchecked texture")));
 | 
						|
            plane.texture(unchecked);
 | 
						|
            sb::Texture checked;
 | 
						|
            checked.load(configuration()("texture", button.at("checked texture")));
 | 
						|
            plane.texture(checked);
 | 
						|
            aspect_ratio = button.at("aspect ratio");
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            /* Create a text plane */
 | 
						|
            glm::ivec2 dimensions { button.at("dimensions") };
 | 
						|
            sb::Text message {
 | 
						|
                fonts.at(configuration()("operator", "font")),
 | 
						|
                button.at("text"),
 | 
						|
                configuration()("operator", "foreground").get<glm::vec4>(),
 | 
						|
                configuration()("operator", "background").get<glm::vec4>(),
 | 
						|
                dimensions
 | 
						|
            };
 | 
						|
            message.refresh();
 | 
						|
            plane = message;
 | 
						|
            aspect_ratio = float(dimensions.y) / dimensions.x;
 | 
						|
        }
 | 
						|
 | 
						|
        /* Add the text plane to a pad object */
 | 
						|
        sb::Pad<> pad { plane, button.at("translation"), button.at("scale"), aspect_ratio };
 | 
						|
 | 
						|
        /* Apply saved state if applicable */
 | 
						|
        if (current_state.has_value())
 | 
						|
        {
 | 
						|
            pad.state(current_state.value());
 | 
						|
        }
 | 
						|
 | 
						|
        /* Add a callback to each button in the operator menu */
 | 
						|
        sb::Pad<>::Reaction response;
 | 
						|
        if (name == "save")
 | 
						|
        {
 | 
						|
            response = [&]([[maybe_unused]] bool state){
 | 
						|
                operator_menu_edited = false;
 | 
						|
 | 
						|
                /* Set credits to enabled or disabled based on the checkbox. */
 | 
						|
                preferences.config(operator_menu_buttons.at("enable credits").pressed(), "arcade", "credits enabled");
 | 
						|
 | 
						|
                /* Apply the floating point values in the textboxes to the config. Convert from string to float,
 | 
						|
                 * and check for errors before submitting to the config. */
 | 
						|
                std::string submission { operator_menu_textboxes.at("credits required").content() };
 | 
						|
                long double requirement = std::strtold(submission.c_str(), nullptr);
 | 
						|
                if (errno != ERANGE)
 | 
						|
                {
 | 
						|
                    preferences.config(requirement, "arcade", "credits required");
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    sb::Log::Multi(sb::Log::ERR) << "Error reading credit requirement value: " << submission <<
 | 
						|
                        sb::Log::end;
 | 
						|
                    errno = 0;
 | 
						|
                }
 | 
						|
                submission = operator_menu_textboxes.at("credit increase").content();
 | 
						|
                long double increase = std::strtold(submission.c_str(), nullptr);
 | 
						|
                if (errno != ERANGE)
 | 
						|
                {
 | 
						|
                    preferences.config(increase, "arcade", "credit increase per event");
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    sb::Log::Multi(sb::Log::ERR) << "Error reading credit increase value: " << submission <<
 | 
						|
                        sb::Log::end;
 | 
						|
                    errno = 0;
 | 
						|
                }
 | 
						|
 | 
						|
                /* Apply the credit display name to the config. */
 | 
						|
                preferences.config(operator_menu_textboxes.at("credit display").content(), "arcade", "credit name");
 | 
						|
 | 
						|
                /* Save the Wi-fi settings */
 | 
						|
                preferences.config(operator_menu_textboxes.at("wi-fi network").content(), "system", "wi-fi network");
 | 
						|
                preferences.config(operator_menu_textboxes.at("wi-fi password").content(), "system", "wi-fi password");
 | 
						|
 | 
						|
                /* Save the state to the user's preferences file. */
 | 
						|
                preferences.merge(configuration());
 | 
						|
                preferences.save(configuration()("storage", "preferences file"));
 | 
						|
 | 
						|
#if defined(__LINUX__)
 | 
						|
                if (!configuration()("system", "wi-fi network").empty())
 | 
						|
                {
 | 
						|
                    /* Add WiFi network via NetworkManager on Linux only. If a connection with the same name exists in
 | 
						|
                     * NetworkManager, the add operation will not modify the existing password. Therefore, delete any
 | 
						|
                     * existing connections with the same name before adding. */
 | 
						|
                    std::string network { configuration()("system", "wi-fi network") };
 | 
						|
                    std::string key { configuration()("system", "wi-fi password") };
 | 
						|
 | 
						|
                    /* The single quote is invalid because the parameters are enclosed in single quotes. The parameters
 | 
						|
                     * must be a valid length. */
 | 
						|
                    std::string invalid_characters { "'" };
 | 
						|
                    if (strcspn(network.c_str(), invalid_characters.c_str()) == network.size() &&
 | 
						|
                        strcspn(key.c_str(), invalid_characters.c_str()) == key.size() &&
 | 
						|
                        network.size() <= 32 && key.size() <= 64)
 | 
						|
                    {
 | 
						|
                        /* Check if a command processor exists */
 | 
						|
                        if (std::system(nullptr) > 0)
 | 
						|
                        {
 | 
						|
                            /* Build command for deleting existing connection */
 | 
						|
                            std::ostringstream command;
 | 
						|
                            command << "nmcli connection delete '" << network << "'";
 | 
						|
 | 
						|
                            /* To run successfully, this requires NetworkManager to be installed. */
 | 
						|
                            sb::Log::Multi() << "Deleting any existing connections in NetworkManager for " <<
 | 
						|
                                network << sb::Log::end;
 | 
						|
                            int status = std::system(command.str().c_str());
 | 
						|
                            if (WIFEXITED(status) == 0)
 | 
						|
                            {
 | 
						|
                                /* Report signal which stopped/ended the process */
 | 
						|
                                if (WIFSIGNALED(status) == 0)
 | 
						|
                                {
 | 
						|
                                    if (WIFSTOPPED(status) > 0)
 | 
						|
                                    {
 | 
						|
                                        sb::Log::Multi() << "Deletion stopped with signal " << WTERMSIG(status) <<
 | 
						|
                                            sb::Log::end;
 | 
						|
                                    }
 | 
						|
                                }
 | 
						|
                                else
 | 
						|
                                {
 | 
						|
                                    sb::Log::Multi() << "Deletion ended with signal " << WTERMSIG(status) <<
 | 
						|
                                        sb::Log::end;
 | 
						|
                                }
 | 
						|
                            }
 | 
						|
                            else
 | 
						|
                            {
 | 
						|
                                sb::Log::Multi() << "Deletion exit status is " << WEXITSTATUS(status) << sb::Log::end;
 | 
						|
                            }
 | 
						|
 | 
						|
                            /* Build command for adding a new connection */
 | 
						|
                            command = std::ostringstream("");
 | 
						|
                            command << "nmcli device wifi connect '" << network << "' password " << key;
 | 
						|
 | 
						|
                            /* To run successfully, this requires NetworkManager to be installed. */
 | 
						|
                            sb::Log::Multi() << "Adding a connection to NetworkManager for " << network <<
 | 
						|
                                sb::Log::end;
 | 
						|
                            status = std::system(command.str().c_str());
 | 
						|
                            if (WIFEXITED(status) == 0)
 | 
						|
                            {
 | 
						|
                                /* Report signal which stopped/ended the process */
 | 
						|
                                if (WIFSIGNALED(status) == 0)
 | 
						|
                                {
 | 
						|
                                    if (WIFSTOPPED(status) > 0)
 | 
						|
                                    {
 | 
						|
                                        sb::Log::Multi() << "Addition stopped with signal " << WTERMSIG(status) <<
 | 
						|
                                            sb::Log::end;
 | 
						|
                                    }
 | 
						|
                                }
 | 
						|
                                else
 | 
						|
                                {
 | 
						|
                                    sb::Log::Multi() << "Addition ended with signal " << WTERMSIG(status) <<
 | 
						|
                                        sb::Log::end;
 | 
						|
                                }
 | 
						|
                            }
 | 
						|
                            else
 | 
						|
                            {
 | 
						|
                                sb::Log::Multi() << "Addition exit status is " << WEXITSTATUS(status) << sb::Log::end;
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        sb::Log::Multi() << "Network name or password is invalid. Check the length and remove any" <<
 | 
						|
                            "single quote characters." << sb::Log::end;
 | 
						|
                    }
 | 
						|
                }
 | 
						|
#endif
 | 
						|
 | 
						|
                /* Reload */
 | 
						|
                load_operator_menu();
 | 
						|
            };
 | 
						|
        }
 | 
						|
        else if (name == "exit")
 | 
						|
        {
 | 
						|
            response = [&]([[maybe_unused]] bool state){
 | 
						|
                if (operator_menu_edited && !operator_menu_confirming)
 | 
						|
                {
 | 
						|
                    operator_menu_confirming = true;
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    operator_menu_active = false;
 | 
						|
                    operator_menu_edited = false;
 | 
						|
                    operator_menu_confirming = false;
 | 
						|
                    operator_menu_index_selected = 0;
 | 
						|
                    set_up_buttons();
 | 
						|
                }
 | 
						|
 | 
						|
                /* Reload */
 | 
						|
                load_operator_menu(true);
 | 
						|
            };
 | 
						|
        }
 | 
						|
        else if (name == "enable credits")
 | 
						|
        {
 | 
						|
            response = [&]([[maybe_unused]] bool state){
 | 
						|
                operator_menu_edited = true;
 | 
						|
                load_operator_menu(true);
 | 
						|
            };
 | 
						|
        }
 | 
						|
        pad.on_state_change(response);
 | 
						|
 | 
						|
        /* Store button in a map that identifies it by name. */
 | 
						|
        buttons[name] = pad;
 | 
						|
    }
 | 
						|
    operator_menu_buttons = buttons;
 | 
						|
 | 
						|
    /* Create text input boxes */
 | 
						|
    std::map<std::string, Textbox> textboxes;
 | 
						|
    for (const auto& [name, input] : configuration()("operator", "input").items())
 | 
						|
    {
 | 
						|
        std::ostringstream content;
 | 
						|
        if (preserve && operator_menu_textboxes.count(name) > 0)
 | 
						|
        {
 | 
						|
            content << operator_menu_textboxes.at(name).content();
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            if (name == "credits required")
 | 
						|
            {
 | 
						|
                content << configuration()("arcade", "credits required");
 | 
						|
            }
 | 
						|
            else if (name == "credit increase")
 | 
						|
            {
 | 
						|
                content << configuration()("arcade", "credit increase per event");
 | 
						|
            }
 | 
						|
            else if (name == "credit display")
 | 
						|
            {
 | 
						|
                content << configuration()("arcade", "credit name").get<std::string>();
 | 
						|
            }
 | 
						|
            else if (name == "wi-fi network")
 | 
						|
            {
 | 
						|
                content << configuration()("system", "wi-fi network").get<std::string>();
 | 
						|
            }
 | 
						|
            else if (name == "wi-fi password")
 | 
						|
            {
 | 
						|
                content << configuration()("system", "wi-fi password").get<std::string>();
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        /* Create a text plane */
 | 
						|
        sb::Text message {
 | 
						|
            fonts.at(configuration()("operator", "font")), content.str(),
 | 
						|
            configuration()("operator", "foreground").get<glm::vec4>(),
 | 
						|
            configuration()("operator", "background").get<glm::vec4>()
 | 
						|
        };
 | 
						|
        if (!content.str().empty())
 | 
						|
        {
 | 
						|
            message.refresh();
 | 
						|
        }
 | 
						|
 | 
						|
        /* Create a textbox */
 | 
						|
        Textbox textbox {
 | 
						|
            message, increment_texture, decrement_texture, input.at("selection"), true,
 | 
						|
            input.value("allow empty", false), input.at("max"), configuration()("operator", "arrow height")
 | 
						|
        };
 | 
						|
        textbox.glyph_font(fonts.at(configuration()("operator", "glyph font")));
 | 
						|
        textbox.translate(input.at("translation").get<glm::fvec2>());
 | 
						|
        textbox.scale(configuration()("operator", "scale"));
 | 
						|
 | 
						|
        /* Use emplace because textbox doesn't have a default constructor */
 | 
						|
        textboxes.emplace(name, textbox);
 | 
						|
    }
 | 
						|
    operator_menu_textboxes = textboxes;
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::draw_operator_menu(
 | 
						|
    const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform)
 | 
						|
{
 | 
						|
    /* Bind plane attributes */
 | 
						|
    sb::Plane::position->bind("vertex_position", shader_program);
 | 
						|
    sb::Plane::color->bind("vertex_color", shader_program);
 | 
						|
 | 
						|
    /* List of selectable menu items and the name of the currently selected item */
 | 
						|
    const nlohmann::json& target_names = configuration()("operator", "order");
 | 
						|
    const std::string& target_name { target_names[operator_menu_index_selected] };
 | 
						|
 | 
						|
    /* Draw text labels. Track whether the label is found so that buttons of the same name aren't highlighted. */
 | 
						|
    bool found = false;
 | 
						|
    for (const auto& [name, label] : operator_menu_labels)
 | 
						|
    {
 | 
						|
        if (name == target_name)
 | 
						|
        {
 | 
						|
            glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
            found = true;
 | 
						|
        }
 | 
						|
        label.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));    
 | 
						|
        glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Draw buttons */
 | 
						|
    for (auto& [name, button] : operator_menu_buttons)
 | 
						|
    {
 | 
						|
        if (!found && name == target_name)
 | 
						|
        {
 | 
						|
            glUniform4fv(uniform.at("color_addition"), 1, &rotating_hue.normal()[0]);
 | 
						|
        }
 | 
						|
        button.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));    
 | 
						|
        glUniform4fv(uniform.at("color_addition"), 1, &glm::vec4(0)[0]);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Draw textboxes */
 | 
						|
    for (auto& [name, textbox] : operator_menu_textboxes)
 | 
						|
    {
 | 
						|
        textbox.draw(view, projection, uniform);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
void Cakefoot::quit()
 | 
						|
{
 | 
						|
    /* Save and sync stats one last time. Steam handles syncing stats at quit on it's own. */
 | 
						|
    write_progress(true, false, true);
 | 
						|
 | 
						|
    controller.reset();
 | 
						|
    super::quit();
 | 
						|
}
 | 
						|
 | 
						|
#if defined(EMSCRIPTEN)
 | 
						|
EM_BOOL respond_to_visibility_change(
 | 
						|
    [[maybe_unused]] int event_type, const EmscriptenVisibilityChangeEvent* visibility_change_event, void* user_data)
 | 
						|
{
 | 
						|
    Cakefoot* game = reinterpret_cast<Cakefoot*>(user_data);
 | 
						|
    if (visibility_change_event->hidden && !game->paused())
 | 
						|
    {
 | 
						|
        sb::Delegate::post("pause", false);
 | 
						|
    }
 | 
						|
    return true;
 | 
						|
}
 | 
						|
 | 
						|
EM_BOOL respond_to_gamepad_connected(
 | 
						|
    [[maybe_unused]] int event_type, [[maybe_unused]] const EmscriptenGamepadEvent* gamepad_event, void* user_data)
 | 
						|
{
 | 
						|
    Cakefoot* game = reinterpret_cast<Cakefoot*>(user_data);
 | 
						|
    game->open_game_controller();
 | 
						|
    return true;
 | 
						|
}
 | 
						|
 | 
						|
extern "C"
 | 
						|
{
 | 
						|
    void pause_for_ads()
 | 
						|
    {
 | 
						|
        sb::Delegate::post("pause for ads", false);
 | 
						|
    }
 | 
						|
    
 | 
						|
    void unpause_for_ads()
 | 
						|
    {
 | 
						|
        sb::Delegate::post("unpause for ads", false);
 | 
						|
    }
 | 
						|
}
 | 
						|
#endif
 |