cakefoot/src/Cakefoot.cpp
Cocktail Frank fa26297dc5 Use new shader loading functions and switch HTTP authorization method
Use new upstream feature to combine multiple files into a single shader
program. Load and use shaders using library functions. Separate the
background shader function into separate files.

Replace whitespace in uniform names with underscores so they are the
same names that appear in the shader code.

Switch from username/password to base64 authorization for HTTP requests
because of upstream fix.

Collect session data more frequently - every 30s instead of every 2m.

Add delay so the collect session data and update game animations don't
run immediately when played.

Add option to exclude platforms in the release script.
2025-04-26 21:29:47 -04:00

5711 lines
237 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 */
character.profile(configuration()("character", "profile", profile_index, "name"));
/* 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();
/* 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();
/* Start tracking hue rotation */
shift_hue_animation.frame_length(configuration()("display", "hue shift frequency"));
shift_hue_animation.play();
/* Start screen effect animations */
blink_animation.play();
warning_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);
}
}
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").press();
}
if (!configuration()("audio", "bgm muted"))
{
button.at("bgm").press();
}
if (!configuration()("audio", "sfx muted"))
{
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);
}
/* 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");
/* Dynamically select version based on whether the build is for OpenGL or OpenGL ES. */
std::string version;
if (configuration()("display", "render driver") == "opengl")
{
version = "150";
}
else
{
version = "300 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.");
}
/* Set the active texture once at context load time because only one texture is used per draw */
glActiveTexture(GL_TEXTURE0);
/* Enable alpha rendering, disable depth test, set clear color */
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_BLEND);
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");
use_shader_program(shader_programs.at("earthbound"));
}
void Cakefoot::use_shader_program(GLuint program)
{
/* Call glUseProgram and assign uniform IDs */
sb::shader::use(program, 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);
/* 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", program);
/* 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 = program;
}
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 = glm::vec2{configuration()("button", "text dimensions")};
scale = configuration()("button", "text scale");
}
else if (name == "fullscreen text" || name == "bgm" || name == "sfx" || name == "exit")
{
dimensions = glm::vec2{configuration()("button", "fullscreen text dimensions")};
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));
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;
}
}
else if (challenge_name == "OPTIONS")
{
/* Move menu back to quest if game is started from a non-play mode menu */
button.at("challenge increment").press();
button.at("challenge increment").press();
button.at("challenge increment").press();
/* Set up for resume quest */
challenge_index = 0;
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")};
}
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";
/* 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();
}
/* 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 */
sb::Texture qr_texture {configuration()("display", "qr texture").get<std::string>()};
qr_texture.load();
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"));
sb::Texture qr_bg_texture {configuration()("display", "qr background texture").get<std::string>()};
qr_bg_texture.load();
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 {configuration()("texture", "auto save").get<std::string>(),
configuration()("display", "auto save scale").get<glm::vec2>(), GL_LINEAR};
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 */
sb::Texture demo_message_texture {configuration()("texture", "demo message").get<std::string>()};
demo_message_texture.load();
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()
{
sb::Texture missing_texture {configuration()("texture", "coin missing").get<std::string>()};
missing_texture.filter(GL_LINEAR);
missing_texture.load();
sb::Texture collected_texture {configuration()("texture", "coin").get<std::string>()};
collected_texture.filter(GL_LINEAR);
collected_texture.load();
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 ? collected_texture : 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()];
}
void Cakefoot::load_level(int index)
{
/* 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)
{
selected = "start";
}
else
{
selected = "resume";
}
/* Wrap the index if it is out of range. */
index = glm::mod(index, static_cast<int>(configuration()("levels").size()));
/* Play menu theme on title screen and end screen. Play main theme on any other level. */
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;
character.beginning(curve(), configuration()("challenge", challenge_index, "name") != "LEVEL SELECT");
coin_collected = false;
coin_returned = false;
let_go = false;
/* 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>(
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>(
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>(
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>(
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>(
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>(
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 {
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;
}
float best = configuration()("progress", "quest best");
if (best <= 0.0f || run_timer.elapsed() < best)
{
configuration()["progress"]["quest best"] = run_timer.elapsed();
label.at("quest best").content(
configuration()("display", "quest best text").get<std::string>() + " " +
format_clock(run_timer.elapsed()));
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.elapsed() < stat_progress.stat_value(stats["STAT_FASTEST_QUEST_TIME"]))
{
stat_progress.set_stat(
stats["STAT_FASTEST_QUEST_TIME"], run_timer.elapsed(), achievements, stats);
}
/* Unlock speed achievements */
if (run_timer.elapsed() <= 60.0f * 60.0f)
{
stat_progress.unlock_achievement(achievements["ACH_KNEAD_FOR_SPEED"]);
if (run_timer.elapsed() <= 30.0f * 60.0f)
{
stat_progress.unlock_achievement(achievements["ACH_CAKEFEAT"]);
} } } }
write_progress(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. */
else if (index > 0)
{
/* 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;
write_progress(true, true);
level_select_index = index;
}
/* If it's the title or end level, stop the run timer. */
if (index == 0 || end_screen())
{
run_timer.off();
arcade_limit_warning = false;
/* In arcade mode, reset the clock on the title screen */
if (arcade() && index == 0)
{
run_timer.reset();
}
}
else
{
/* If it's the first level, start the run timer from the beginning. */
if (index == 1)
{
run_timer.reset();
}
run_timer.on();
}
/* Reset and start the survival and stray timers */
if (index != 0 && !end_screen())
{
survival_timer.reset();
survival_timer.on();
stray_timer.reset();
stray_timer.on();
}
/* 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 */
set_up_hud();
/* Refresh the buttons so the level select will reflect the change in level */
set_up_buttons();
/* Set the color background and music according to current world. Use world 0 color for the end screen. */
nlohmann::json world = configuration()("world");
if (static_cast<std::size_t>(index) == configuration()("levels").size() - 1)
{
world_color = world[0].at("color").get<glm::fvec4>();
}
else
{
for (std::size_t ii = 0; ii < world.size(); ii++)
{
if (ii == world.size() - 1 || world[ii + 1].at("start").get<int>() > index)
{
bgm.set(ii);
world_color = world[ii].at("color").get<glm::fvec4>();
break;
} } }
/* Flash the screen at the start of a level */
flash_animation.play_once(configuration()("display", "flash length"));
/* Load the appropriate background shader based on the level index */
use_shader_program(shader_programs.at("earthbound"));
#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 {
{"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(
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", "") };
/* If the platform is unspecified, and the Steam API is connected, it can be assumed the platform is Steam. */
std::string platform { configuration()("session").value("platform", "") };
#if defined(STEAM_ENABLED)
if (platform.empty() && sb::cloud::steam::initialized())
{
platform = "steam";
}
#endif
/* Send the session data to the configured receiver URL */
http.post_session(
configuration()("session", "receiver", "url"),
stat_progress.session(),
configuration()("session", "title"),
cakefoot::version,
platform,
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(
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
{
sb::Log::Line(sb::Log::DEBUG) << "Parsing bank serialized to " + bank_serialized();
std::vector<bool> bank { bank_parse(bank_serialized()) };
sb::Log::Multi(sb::Log::DEBUG) << "Bank parsed as " << bank << sb::Log::end;
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();
} } }
}
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();
/* 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 not displayed on the achievements and stats menus */
if (challenge_name != "ACHIEVEMENTS" && challenge_name != "STATS")
{
sb::extend(title_menu, {"start"});
}
/* The challenge spinner is always available unless explictly 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")
{
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 cancelled. */
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 = "confirm";
}
else if (sb::Delegate::compare(event, "right"))
{
selected = "cancel";
}
else if (sb::Delegate::compare(event, "any"))
{
press_confirm = selected == "confirm";
press_cancel = selected == "cancel";
}
else if (event.type == SDL_MOUSEMOTION)
{
hovering = collide_confirm || collide_cancel;
if (collide_confirm)
{
selected = "confirm";
}
else if (collide_cancel)
{
selected = "cancel";
}
}
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;
/* 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())
{
selected = nearest_button(selected.value(), sb::Delegate::event_command(event), title_menu);
}
else if (sb::contains(title_menu, "start"))
{
selected = "start";
}
else
{
selected = "challenge decrement";
}
}
}
/* Execute menu action */
if (sb::Delegate::compare(event, "any"))
{
button_pressed = true;
if (!selected.has_value())
{
button.at("start").press();
}
else
{
button.at(selected.value()).press();
}
}
}
/* 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 = "resume";
}
}
else if (selected.has_value() && sb::Delegate::compare(event, "any"))
{
button.at(selected.value()).press();
}
}
/* Perspective and view modifications */
if (event.type == SDL_MOUSEWHEEL && shift_pressed)
{
/* Edit zoom level with mouse wheel, which will modify the FOV */
zoom -= event.wheel.preciseY * glm::radians(2.0f);
if (zoom < glm::radians(-30.0f))
{
zoom = glm::radians(-30.0f);
}
else if (zoom > glm::radians(30.0f))
{
zoom = glm::radians(30.0f);
}
}
else if (event.type == SDL_MOUSEBUTTONUP || sb::Delegate::compare_cancel(event, "any"))
{
/* End character acceleration */
if (sb::Delegate::compare_cancel(event, "any") ||
(event.type == SDL_MOUSEBUTTONUP && event.button.button == SDL_BUTTON_LEFT))
{
character.accelerating = false;
}
}
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)
{
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 = 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_name;
if (event.type == SDL_MOUSEBUTTONDOWN)
{
button.at(button_name).press();
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)
{
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 = "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();
}
}
}
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();
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);
}
}
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();
}
/* 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;
}
std::string Cakefoot::nearest_button(
const std::string& subject, const std::string& direction, const std::map<std::string, sb::Pad<>>& pool) const
{
std::optional<std::string> nearest;
float closest_distance;
for (const auto& [name, pad] : pool)
{
/* Don't search against self or disabled buttons */
if (name != subject && pad.enabled())
{
/* Compare box positions of two buttons */
const sb::Box& box_a {button.at(subject).box()};
const sb::Box& box_b {pad.box()};
/* Check distance based on direction */
if (direction == "up" || direction == "down")
{
float dy = direction == "up" ? box_a.top() - box_b.bottom() : box_a.bottom() - box_b.top();
if ((direction == "up" && dy < 0) || (direction == "down" && dy > 0))
{
if (!nearest.has_value() || std::abs(dy) < closest_distance)
{
nearest = name;
closest_distance = std::abs(dy);
}
else if (std::abs(dy) == closest_distance)
{
float box_b_dx = box_a.cx() - box_b.cx();
float current_dx = box_a.cx() - button.at(nearest.value()).box().cx();
if (std::abs(box_b_dx) < std::abs(current_dx))
{
nearest = name;
closest_distance = std::abs(dy);
} } } }
else if (direction == "right" || direction == "left")
{
float dx = direction == "right" ? box_a.right() - box_b.left() : box_a.left() - box_b.right();
if ((direction == "right" && dx < 0) || (direction == "left" && dx > 0))
{
if (!nearest.has_value() || std::abs(dx) < closest_distance)
{
nearest = name;
closest_distance = std::abs(dx);
}
else if (std::abs(dx) == closest_distance)
{
float box_b_dy = box_a.cy() - box_b.cy();
float current_dy = box_a.cy() - button.at(nearest.value()).box().cy();
if (std::abs(box_b_dy) < std::abs(current_dy))
{
nearest = name;
closest_distance = std::abs(dx);
} } } } } }
if (nearest.has_value())
{
return nearest.value();
}
else
{
return subject;
}
}
std::string Cakefoot::nearest_button(
const std::string& subject, const std::string& direction, const std::vector<std::string>& names) const
{
std::map<std::string, sb::Pad<>> subset;
for (const std::string& name : names)
{
subset[name] = button.at(name);
}
return nearest_button(subject, direction, subset);
}
std::string Cakefoot::nearest_button(const std::string& subject, const std::string& direction) const
{
return nearest_button(subject, direction, button);
}
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();
}
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"));
/* Reload user interface elements */
set_up_buttons();
set_up_hud();
load_achievements_menu();
load_stats_menu();
load_operator_menu();
load_confirmation_alert();
}
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);
#if defined(__LINUX__)
update_game_version_animation.update(timestamp);
#endif
/* Update HTTP queue */
#if defined(HTTP_ENABLED)
http.update(timestamp);
#endif
/* 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. */
float fov;
if (window_box().aspect() >= curve().aspect)
{
fov = 2.0f * glm::atan(1.0f / camera_position.z) + zoom;
}
else
{
fov = 2.0f * glm::atan(
((1.0f / (window_box().width() * (9.0f / 16.0f))) * window_box().height()) / camera_position.z) +
zoom;
}
projection = glm::perspective(fov, window_box().aspect(), 0.1f, 100.0f);
/* Rotate X 180 if mirror mode is active */
float rotation_x = rotation.x;
float rotation_y = rotation.y;
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, 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);
/* 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())
{
/* Update other timers */
run_timer.update(timestamp);
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();
}
unpaused_timer.update(timestamp);
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. */
if (level_index > 0 &&
((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 &&
survival_timer.elapsed() >= achievements["ACH_SHAKE_N_BAKE"].json().at("goal").get<float>())
{
stat_progress.unlock_achievement(achievements["ACH_SHAKE_N_BAKE"]);
write_progress(true, true);
}
else if (level_index == 14)
{
/* 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"]);
write_progress(true, true);
}
}
/* Distance tracking */
float check = distance();
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 */
auto& maximum_distance = configuration()["progress"][
"arcade max distance"].get_ref<nlohmann::json::number_integer_t&>();
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;
}
/* 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, and trigger a pop up if so. */
if (!stat_progress.live_achievements().empty())
{
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);
/* 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");
}
character.update(
curve(),
unpaused_timer,
character_ndc,
!button.at("volume").pressed(),
constant_speed
);
if (character.at_end(curve()))
{
/* 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 */
write_progress(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);
write_progress(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"]);
}
/* 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"]);
write_progress(true, true);
}
/* 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
{
/* 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)
{
continuous_timer.on();
}
else
{
continuous_timer.reset();
continuous_timer.off();
if (!character.resting())
{
let_go = true;
}
}
if (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"))
{
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>());
/* 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();
}
write_progress();
} } }
/* Check for Icing achievement on level 2 */
if (level_index == 2)
{
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"]);
write_progress(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 (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();
}
/* 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() && 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);
configuration()["progress"][quest() ? "quest deaths" : "arcade deaths"].
get_ref<nlohmann::json::number_integer_t&>()++;
/* 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"]);
write_progress(true, true);
}
/* Check for wandering fish achievement */
if (wanderer)
{
stat_progress.unlock_achievement(achievements["ACH_JUST_DESSERTS"]);
write_progress(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 */
write_progress();
/* Reset distance tracking */
previous_distance.reset();
}
/* Keep a count of meters walked */
if (previous_distance.has_value())
{
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())
{
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();
}
/* Save progress and stats periodically when the game is idle. Update consecutive days played
* count. */
if (save_stats && (character.resting() || level_index == 0))
{
/* 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");
write_progress();
}
/* 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()))
{
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"]);
}
}
}
}
/* 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);
/* Reset color addition, and draw curve. */
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]);
}
glm::mat4 vp = projection * view * rotation_matrix;
glUniformMatrix4fv(uniform.at("mvp"), 1, GL_FALSE, &vp[0][0]);
curve().color.bind("vertex_color", shader_program);
curve().color.enable();
for (sb::Attributes& position : curve().position)
{
position.bind("vertex_position", shader_program);
position.enable();
glDrawArrays(GL_LINE_STRIP, 0, position.count());
position.disable();
}
curve().color.disable();
/* Bind plane attributes */
sb::Plane::position->bind("vertex_position", shader_program);
sb::Plane::color->bind("vertex_color", shader_program);
/* Draw checkpoints */
if (profile_index == 0 && configuration()("levels", level_index).contains("checkpoints"))
{
for (nlohmann::json checkpoint : configuration()("levels", level_index, "checkpoints"))
{
sb::Sprite* sprite;
if (checkpoint["position"].get<float>() > character.checkpoint())
{
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, projection,
uniform.at("texture_enabled"));
} }
/* Draw enemies */
for (auto& enemy : enemies)
{
enemy->draw(uniform.at("mvp"), view * rotation_matrix, 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 */
character.draw(curve(), uniform.at("mvp"), view * rotation_matrix, projection,
uniform.at("texture_enabled"));
/* Draw end screen coins */
if (end_screen())
{
for (Flame& coin : ending_coins)
{
coin.draw(uniform.at("mvp"), view * rotation_matrix, 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";
/* 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 - don't draw on the achievements and stats menus */
if (challenge_name != "ACHIEVEMENTS" && challenge_name != "STATS")
{
if (configuration()("arcade", "arcade only") || selected == "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 (selected == 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 (std::string name : {"resume", "reset", "fullscreen text", "bgm", "sfx"})
{
if (selected == 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 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"))
{
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 && (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 achievements or stats menu */
if (challenge_name == "ACHIEVEMENTS")
{
draw_achievements(view, projection, uniform);
}
else if (challenge_name == "STATS")
{
draw_stats(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 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);
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");
}
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_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 (selected == "confirm")
{
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 (selected == "cancel")
{
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