cakefoot/src/Cakefoot.hpp
Cocktail Frank dae6e8b779 Add per level background shaders and curve hue shift
Delete existing shaders when loading new shaders. Reload shaders on
reconfig event.
2025-04-29 21:01:00 -04:00

1760 lines
56 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>
`~ ~ ~`~ */
#pragma once
/* Needed for functions in `glm/gtx/` */
#define GLM_ENABLE_EXPERIMENTAL
/* Standard library includes */
#include <cstdlib>
#include <stdlib.h>
#include <string>
#include <iostream>
#include <iomanip>
#include <map>
#include <memory>
#include <functional>
#if !defined(__MACOS__)
#include <malloc.h>
#endif
#include <chrono>
#include <ctime>
#include <locale>
#include <codecvt>
#include <cerrno>
/* Include Game.hpp before any other SDL-related headers because it defines SDL_MAIN_HANDLED */
#include "Game.hpp"
/* SPACEBOX external libraries included in source package */
#include "SDL_mixer.h"
#include "sdl2-gfx/SDL2_gfxPrimitives.h"
#include "json/json.hpp"
#include "glm/glm.hpp"
#include "glm/gtx/matrix_decompose.hpp"
#include "glm/gtc/matrix_access.hpp"
#include "cli11/CLI11.hpp"
#include "catch2/catch_amalgamated.hpp"
/* SPACEBOX classes and functions */
#include "Audio.hpp"
#include "Color.hpp"
#include "extension.hpp"
#include "filesystem.hpp"
#include "Animation.hpp"
#include "Texture.hpp"
#include "GLObject.hpp"
#include "Log.hpp"
#include "Attributes.hpp"
#include "VBO.hpp"
#include "Model.hpp"
#include "Box.hpp"
#include "Switch.hpp"
#include "Selection.hpp"
#include "math.hpp"
#include "Configuration.hpp"
#include "Timer.hpp"
#include "Text.hpp"
#include "Segment.hpp"
#include "Input.hpp"
#include "progress.hpp"
/* Project-specific headers */
#include "Character.hpp"
#include "Sprite.hpp"
#include "Pad.hpp"
#include "Curve.hpp"
#include "Enemy.hpp"
#include "version.hpp"
/*!
* Container for a list of arcade scores that keeps scores sorted as they are added.
*/
class ArcadeScores
{
public:
struct Score
{
float time = 0.0f;
int distance = 0;
std::string name = "";
std::time_t date;
Score(float time, int distance, const std::string& name = "") :
time(time), distance(distance), name(name), date(std::time(nullptr)) {};
Score() : Score(0.0f, 0) {};
bool operator>(const Score& other) const
{
if (time == other.time)
{
if (distance == other.distance)
{
/* Tie-breaker goes to the earlier recorded score */
return date < other.date;
}
else
{
return distance > other.distance;
}
}
else
{
if (time == 0.0f || other.time == 0.0f)
{
/* If true, this score is a complete run with a valid time entry, and the other score is an
* incomplete run that can only be compared by distance, indicated by the zero time entry.
* Otherwise, the opposite is true. */
return other.time == 0.0f;
}
else
{
/* The higher clock is a better score because there was more time remaining at the end of the run.
*/
return time > other.time;
}
}
}
};
private:
std::vector<Score> scores;
public:
int count() const
{
return scores.size();
}
/*!
* @return Read-only vector of scores sorted
*/
operator const std::vector<Score>&() const
{
return scores;
}
/*!
* @return The best score as a score object
*
* @throw std::out_of_range If the list of scores is empty
*/
const Score& best() const
{
if (count() < 1)
{
throw std::out_of_range("There are no scores available, so there is no best score.");
}
return scores[0];
}
void add(const Score& incoming)
{
auto score = scores.begin();
for (; score != scores.end(); score++) if (incoming > *score) break;
scores.insert(score, incoming);
}
int rank(const Score& other) const
{
std::size_t index = 0;
for (; index < scores.size() && scores[index] > other; index++);
return index + 1;
}
nlohmann::json json(const std::string& date_format) const
{
nlohmann::json json;
for (const ArcadeScores::Score& score : scores)
{
std::ostringstream date;
date << std::put_time(std::localtime(&score.date), date_format.c_str());
nlohmann::json entry = {
{"name", score.name},
{"time", score.time},
{"distance", score.distance},
{"date", date.str()}
};
json.push_back(entry);
}
return json;
}
std::string formatted(int rows, int cols) const
{
std::size_t index = 0;
std::string name;
std::ostringstream text;
for (int row = 0; row < rows; row++)
{
for (int col = 0; col < cols; col++)
{
std::ostringstream score;
index = cols * col + row;
if (index >= scores.size())
{
name = "---";
score << "";
}
else
{
name = scores[index].name;
if (scores[index].time > 0.0f)
{
score << std::setprecision(1) << std::fixed << scores[index].time;
}
else
{
score << scores[index].distance << "m";
}
}
text << std::setw(2) << index + 1 << ". " << name << " " << std::setw(6) << score.str() << " ";
if (col == cols - 1)
{
text << std::endl;
}
}
}
return text.str();
}
/*!
* @return Count total distance logged by the entire scoreboard.
*/
int total_distance() const
{
int total = 0;
for (const Score& score : scores)
{
total += score.distance;
}
return total;
}
};
/*!
* Image to display at game start up, includes a background color and length in seconds to display.
*/
struct Splash
{
sb::Sprite sprite;
sb::Color background;
float length;
};
class MultiBGM
{
private:
bool _enabled = true;
std::vector<sb::audio::Music> files;
std::size_t _index = 0;
sb::audio::Music& current()
{
return files.at(_index);
}
const sb::audio::Music& current() const
{
return files.at(_index);
}
public:
/*!
* The file will not be loaded into memory, but an SDL_Music pointer will be created, which will be used to stream
* the contents of the file when it is played. The pointer will be unloaded automatically when the MultiBGM goes
* out of scope.
*
* @param path An audio file which will be streamed by this object
*/
void load(const fs::path& path)
{
sb::Log::Multi() << "Loading audio at " << path << sb::Log::end;
sb::audio::Music file {path};
files.push_back(file);
}
/*!
* Set a file to active. If the file is already active, do nothing. Otherwise play the file from the beginning,
* stopping any other files currently playing.
*
* @param index The index of a file to set to active
*
* @throw std::out_of_range If container does not contain a file at the given index
*/
void set(std::size_t index)
{
if (index < files.size())
{
if (index != _index)
{
_index = index;
if (playing())
{
current().play();
}
}
}
else
{
std::ostringstream message;
message << "Multi-bgm does not contain a FILE at index " << index;
throw std::out_of_range(message.str());
}
}
/*!
* Play the currently active file.
*/
void play()
{
if (_enabled)
{
current().play(-1);
}
}
/*!
* Pause streaming audio.
*/
void pause()
{
Mix_PauseMusic();
}
/*!
* Resume streaming audio.
*/
void resume()
{
if (_enabled)
{
Mix_ResumeMusic();
}
}
/*!
* @return True if streaming audio is paused, false otherwise
*/
bool paused() const
{
return Mix_PausedMusic();
}
/*!
* @return True if streaming audio is playing, false otherwise
*/
bool playing() const
{
return Mix_PlayingMusic();
}
/*!
* @return True if streaming audio is currently fading out
*/
bool fading() const
{
return Mix_FadingMusic();
}
/*!
* Stop the streaming music player
*/
void stop()
{
Mix_HaltMusic();
}
/*!
* By default, a multi-bgm object is enabled to play audio. If disabled with this function, all bgm in the
* multi-bgm will be disabled, and no audio will be able to be played. The multi-bgm will need to be enabled
* again to be able to play any audio.
*
* This function can be used both to set the state and to check the state.
*
* @param state Set to false to disable the multi-chunk, true to enable, or omit to check the current state
* @return True if multi-chunk is set to enabled
*/
bool enabled(std::optional<bool> state = std::nullopt)
{
if (state.has_value())
{
_enabled = state.value();
}
return _enabled;
}
/*!
* @return Index of the currently active file
*/
std::size_t index() const
{
return _index;
}
};
/*!
* Load multiple chunks into a single object, indexed by order loaded.
*
* Load each chunk by passing a file path. Each chunk loaded will be fully loaded into memory. Set the active chunk by
* index, and all audio control functions will apply to the active chunk.
*
* When a multi-chunk is playing, and the active chunk is set to another chunk, the new chunk will start playing from
* the beginning.
*
* All chunks are set to loop infinitely.
*
* When a multi-chunk is disabled, all chunks are disabled. The multi-chunk will need to be re-enabled to be able to
* play any chunk.
*/
class MultiChunk
{
std::vector<sb::audio::Chunk> chunks;
std::size_t _index = 0;
bool _enabled = true;
sb::audio::Chunk& current()
{
return chunks.at(_index);
}
const sb::audio::Chunk& current() const
{
return chunks.at(_index);
}
public:
/*!
* Create an empty MultiChunk object.
*/
MultiChunk() = default;
/*!
* @warning This loads the entire file into memory. It should only be called once per file, and it should not be
* called on more files than can fit into memory. The memory will be unloaded automatically when this object is
* destructed.
*
* @param path Path to an audio file to be loaded into memory and stored in this object
*/
void load(const fs::path& path)
{
sb::Log::Multi() << "Loading audio at " << path << sb::Log::end;
sb::audio::Chunk chunk {path};
chunk.loop();
chunks.push_back(chunk);
}
/*!
* Set a chunk to active. If the chunk is already active, do nothing. If another chunk is currently playing, stop
* that chunk and play the new one from the beginning.
*
* @param index The index of a chunk to set to active
*
* @throw std::out_of_range If the multi-chunk does not contain a chunk at the given index
*/
void set(std::size_t index)
{
if (index < chunks.size())
{
if (index != _index)
{
bool swap = current().playing();
if (swap)
{
current().stop();
}
_index = index;
if (swap)
{
current().play();
}
}
}
else
{
std::ostringstream message;
message << "Multi-chunk does not contain a chunk at index " << index;
throw std::out_of_range(message.str());
}
}
/*!
* Play the currently active chunk.
*/
void play()
{
current().play();
}
/*!
* Pause the currently active chunk.
*/
void pause()
{
current().pause();
}
/*!
* Stop the currently active chunk.
*/
void stop()
{
current().stop();
}
/*!
* Resume the currently active chunk.
*/
void resume()
{
current().resume();
}
/*!
* @return True if the current chunk is paused, false otherwise
*/
bool paused() const
{
return current().paused();
}
/*!
* @return True if the current chunk is playing, false otherwise
*/
bool playing() const
{
return current().playing();
}
/*!
* @return True if the current chunk is fading, false, otherwise
*/
bool fading() const
{
return current().fading();
}
/*!
* By default, a multi-chunk object is enabled to play audio. If disabled with this function, all chunks in the
* multi-chunk will be disabled, and no audio will be able to be played. The multi-chunk will need to be enabled
* again to be able to play any chunk.
*
* This function can be used both to set the state and to check the state.
*
* @param state Set to false to disable the multi-chunk, true to enable, or omit to check the current state
* @return True if multi-chunk is set to enabled
*/
bool enabled(std::optional<bool> state = std::nullopt)
{
if (state.has_value())
{
_enabled = state.value();
for (sb::audio::Chunk& chunk : chunks)
{
chunk.enabled(state);
}
}
return _enabled;
}
/*!
* @return Index of the currently active chunk
*/
std::size_t index() const
{
return _index;
}
};
/*!
* A GUI widget which contains an editable string. Characters can be appended to the string one at a time using a
* spinner widget which appears when edit mode is activated.
*/
class Textbox
{
public:
/*!
* Used to specify which characters are available to the textbox.
*/
struct Selection
{
static inline std::string uppercase { "uppercase" };
static inline std::string decimal { "decimal" };
static inline std::string all { "all" };
};
private:
/* Display */
sb::Sprite _sprite;
sb::Text _text_plane;
/* Edit options */
bool _editable = false;
bool _editing = false;
bool _allow_empty = false;
std::string _selection = Selection::uppercase;
std::size_t max = 0;
/* Next character input */
std::shared_ptr<TTF_Font> _glyph_font;
int character_index = 0;
std::wstring character_set;
sb::Sprite character_sprite;
sb::Sprite up_arrow;
sb::Sprite down_arrow;
std::string original;
float arrow_height = 0.5f;
/* Possible characters to input. Use wchar_t so that the backspace and submit glyphs can be stored in the same array
* as the ASCII characters. The characters will be converted to UTF-8 when passed to sb::Text for display. */
struct Characters
{
static inline std::wstring upper { L"ABCDEFGHIJKLMNOPQRSTUVWXYZ" };
static inline std::wstring lower { L"abcdefghijklmnopqrstuvwxyz" };
static inline std::wstring digits { L"0123456789" };
static inline std::wstring decimal { digits + L"." };
static inline std::wstring special { L" !#$%&'()*+,-./:;<=>?@[]^_`{|}~\"\\" };
static inline wchar_t backspace { u'' };
static inline wchar_t submit { u'' };
};
/*!
* @return The currently selected character
*/
wchar_t character() const
{
return character_set[character_index];
}
public:
Textbox(const sb::Text& text_plane,
const sb::Texture& up_arrow_texture,
const sb::Texture& down_arrow_texture,
std::string selection = Selection::uppercase,
bool editable = false,
bool allow_empty = false,
std::size_t max = 0,
float arrow_height = 0.5f) :
_text_plane(text_plane), _editable(editable), _allow_empty(allow_empty), _selection(selection), max(max),
up_arrow(up_arrow_texture), down_arrow(down_arrow_texture), arrow_height(arrow_height)
{
/* Create a sprite with the given text plane as the graphics */
_sprite = sb::Sprite {text_plane};
/* Create a list of available characters for selection by storing them in a string. Include the backspace and
* submit glyphs in the character list. */
std::basic_ostringstream<wchar_t> set;
if (selection == Selection::uppercase)
{
set << Characters::upper;
}
else if (selection == Selection::decimal)
{
set << Characters::decimal;
}
else if (selection == Selection::all)
{
set << Characters::upper << Characters::lower << Characters::digits << Characters::special;
}
set << Characters::backspace << Characters::submit;
character_set = set.str();
_selection = selection;
/* Create arrow sprites */
up_arrow = sb::Sprite { up_arrow_texture };
down_arrow = sb::Sprite { down_arrow_texture };
}
void glyph_font(std::shared_ptr<TTF_Font> font)
{
_glyph_font = font;
}
void text_plane(const sb::Text& text_plane)
{
_text_plane = text_plane;
}
void editable(bool state = true)
{
_editable = state;
}
void allow_empty(bool state = true)
{
_allow_empty = state;
}
void translate(const glm::vec2& translation)
{
_sprite.translate(translation);
}
void scale(float scale)
{
float ratio { float(_text_plane.dimensions().x) / _text_plane.dimensions().y };
_sprite.scale({ratio * scale, scale});
}
void edit()
{
_editing = true;
/* Store the content value so it can be reset if bad input is submitted */
original = content();
}
bool editing()
{
return _editing;
}
void submit()
{
_editing = false;
}
void content(const std::string message)
{
_text_plane.content(message);
/* If the string is empty, this object will just skip drawing the text in the draw function */
if (!message.empty())
{
_text_plane.refresh();
}
_sprite.plane(_text_plane);
scale(_sprite.scale()[1]);
}
const std::string& content() const
{
return _text_plane.content();
}
void increment_character()
{
character_index++;
/* Wrap */
if (character_index >= int(character_set.size()))
{
character_index = 0;
}
}
void decrement_character()
{
character_index--;
/* Wrap */
if (character_index < 0)
{
character_index = character_set.size() - 1;
}
}
/*!
* @return True if edits were submitted and the content changed, false otherwise
*/
bool press()
{
/* If not editing, toggle editing on */
if (!_editing)
{
edit();
return false;
}
/* Otherwise, submit the current glyph */
else
{
/* Backspace was pressed, so remove the last character in the content */
if (character() == Characters::backspace)
{
if (content().size() > 0)
{
content(content().substr(0, content().size() - 1));
}
return false;
}
/* Submit was pressed, stop editing and submit */
else if (character() == Characters::submit)
{
_editing = false;
/* If the content is empty, restore the original value, unless empty has been allowed. */
if (!_allow_empty && (content().size() == 0 || (_selection == Selection::decimal && content() == ".")))
{
content(original);
return false;
}
/* Clean up if there is no decimal or the decimal is at the beginning or end */
else if (_selection == Selection::decimal)
{
std::string append;
if (content().find('.') == std::string::npos)
{
append = ".0";
}
else if (content().find('.') == content().size() - 1)
{
append = "0";
}
if (append.size() > 0)
{
content(content() + append);
}
else
{
if (content().find('.') == 0)
{
content("0" + content());
}
}
}
/* Content is submitted, so report whether it changed */
return content() != original;
}
/* Add a character to the content */
else
{
/* Check the size is under the maximum */
bool under_minimum { max == 0 || content().size() < max };
/* Check the user is not entering more than one decimal */
bool single_decimal {
character() != u'.' || _selection != Selection::decimal || content().find('.') == std::string::npos
};
if (under_minimum && single_decimal)
{
std::string appended { content() };
appended.push_back(character());
content(appended);
}
/* Content has not been submitted yet */
return false;
} } }
/*!
* Draw text. If editing, draw the character input spinner.
*
* @param view Transformation from world space to camera space
* @param projection Transformation from camera space to clip space
* @param uniform List of uniforms available in the GLSL program
*/
void draw(
const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform)
{
if (!content().empty())
{
_sprite.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
}
/* Draw the spinner to the right of the sprite. */
if (_editing)
{
/* Prefer a glyph font, default to the same font as the text plane */
std::shared_ptr<TTF_Font> font;
if (_glyph_font.get() != nullptr)
{
font = _glyph_font;
}
else
{
font = _text_plane.font();
}
/* Create a text plane with a single glyph. Convert the wchar_t character to a UTF-8 string, so sb::Text
* will be able to display it. */
sb::Text selection { font, "", _text_plane.foreground(), _text_plane.background() };
std::string converted {
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>, wchar_t>{}.to_bytes(character()) };
selection.content(converted);
selection.dimensions({_text_plane.dimensions().y, _text_plane.dimensions().y});
selection.refresh();
/* Create a sprite for the character. Scale and translate it using the properties of the text plane. */
character_sprite = sb::Sprite(selection);
character_sprite.scale({_sprite.scale().y, _sprite.scale().y});
character_sprite.translate(
{_sprite.translation().x + _sprite.scale().x + character_sprite.scale().x,
_sprite.translation().y});
character_sprite.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
/* Scale, translate, and draw the up and down arrows */
glm::vec2 arrow_scale {character_sprite.scale().x, character_sprite.scale().y * arrow_height};
up_arrow.scale(arrow_scale);
up_arrow.translate({
character_sprite.translation().x,
_sprite.translation().y + _sprite.scale().y + arrow_scale.y});
up_arrow.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
down_arrow.scale(arrow_scale);
down_arrow.translate({
character_sprite.translation().x,
_sprite.translation().y - _sprite.scale().y - arrow_scale.y});
down_arrow.draw(uniform.at("mvp"), view, projection, uniform.at("texture_enabled"));
}
}
};
class ProgressWriter
{
typedef std::function<void(bool, bool, bool)> Write;
Write write_progress;
bool force_save_stats { false };
bool force_sync_stats { false };
bool force_sync_session { false };
bool staged { false };
public:
ProgressWriter(Write write_progress) : write_progress(write_progress) {}
void trigger()
{
if (staged)
{
write_progress(force_save_stats, force_sync_stats, force_sync_session);
/* Reset flags on every write */
force_save_stats = false;
force_sync_stats = false;
force_sync_session = false;
staged = false;
}
}
void stage(bool force_save_stats = false, bool force_sync_stats = false, bool force_sync_session = false)
{
if (force_save_stats) this->force_save_stats = true;
if (force_sync_stats) this->force_sync_stats = true;
if (force_sync_session) this->force_sync_session = true;
staged = true;
}
};
/*!
* The main game object. There is currently only support for one of these to exist at a time.
*
* Note: the conversion for pixel distance from the original 864x468 coordinate system to this program's NDC coordinate
* system is
*
* f(distance) = distance / 486.0 * 2
*
* Note: the conversion for pixel speed values from the original Python version's per-frame, fixed 25fps, 864x468
* coordinate system to this program's per-second, adjustable framerate, NDC coordinate system is below. This gives the
* speed in amount of NDC to travel per second. For non-pixel values, just multiply by 25.
*
* f(speed) = speed / 486.0 * 25 * 2
*/
class Cakefoot : public sb::Game
{
private:
/* Convention for calling parent class in a consistent way across classes */
typedef sb::Game super;
/* Static members */
const inline static std::string reset_command_name = "reset";
const inline static fs::path levels_file_path = "resource/levels.json";
const inline static std::string date_format = "%Y/%m/%d %H:%M";
/* Member vars */
std::shared_ptr<SDL_Cursor> poke, grab;
int previous_frames_per_second = 0, curve_index = 0, curve_byte_count = 0, level_index = 0, level_select_index = 1,
profile_index = 0, challenge_index = 0, view_index = 0, name_entry_index = 0, splash_index = 0;
std::map<std::string, GLuint> uniform {
{"mvp", -1},
{"time", -1},
{"model_texture", -1},
{"texture_enabled", -1},
{"color_addition", -1},
{"bg_enabled", -1},
{"resolution", -1},
{"alpha_mod_enabled", -1},
{"bg_color_1", -1},
{"bg_color_2", -1},
{"bg_control_1", -1}
};
std::map<std::string, GLuint> shader_programs;
GLuint shader_program = -1;
glm::mat4 view {1.0f}, projection {1.0f};
sb::VAO vao;
sb::VBO vbo;
std::map<std::string, sb::Pad<>> button = {
{"start", sb::Pad<>()},
{"resume", sb::Pad<>()},
{"reset", sb::Pad<>()},
{"level increment", sb::Pad<>()},
{"level decrement", sb::Pad<>()},
{"pause", sb::Pad<>()},
{"profile increment", sb::Pad<>()},
{"profile decrement", sb::Pad<>()},
{"volume", sb::Pad<>()},
{"play", sb::Pad<>()},
{"challenge increment", sb::Pad<>()},
{"challenge decrement", sb::Pad<>()},
{"view increment", sb::Pad<>()},
{"view decrement", sb::Pad<>()},
{"name 1", sb::Pad<>()},
{"name 1 increment", sb::Pad<>()},
{"name 1 decrement", sb::Pad<>()},
{"name 2", sb::Pad<>()},
{"name 2 increment", sb::Pad<>()},
{"name 2 decrement", sb::Pad<>()},
{"name 3", sb::Pad<>()},
{"name 3 increment", sb::Pad<>()},
{"name 3 decrement", sb::Pad<>()},
{"fullscreen", sb::Pad<>()},
{"diskmem", sb::Pad<>()},
{"azuria sky", sb::Pad<>()},
{"fullscreen text", sb::Pad<>()},
{"bgm", sb::Pad<>()},
{"sfx", sb::Pad<>()},
{"exit", sb::Pad<>()},
{"steam", sb::Pad<>()},
{"dank", sb::Pad<>()}
};
std::map<std::string, std::shared_ptr<TTF_Font>> fonts {
{"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)}
};
std::map<std::string, sb::Text> label = {
{"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"))},
{"credits available", sb::Text(fonts.at(configuration()("arcade", "credits available", "font")))}
};
sb::Sprite playing_field, checkpoint_on, checkpoint_off, qr_code, qr_code_bg, auto_save, demo_message,
coin {configuration()("texture", "coin").get<std::string>(), glm::vec2{12.0f / 486.0f}, GL_LINEAR};
sb::Timer on_timer, run_timer, unpaused_timer, idle_timer, survival_timer, stray_timer, continuous_timer;
glm::vec3 camera_position {0.0f, 0.0f, 2.0f}, subject_position {0.0f, 0.0f, 0.0f};
float zoom = 0.0f, stray = 0.0f;
glm::vec2 rotation = {0.0f, 0.0f};
std::vector<Curve> curves;
std::vector<std::shared_ptr<Enemy>> enemies;
glm::vec4 world_color {0.2f, 0.2f, 0.2f, 1.0f};
Character character {_configuration, audio};
bool use_play_button = false, coin_collected = false, blinking_visible = true, arcade_limit_warning = false,
coin_returned = false, let_go = false, icing_available = false;
ArcadeScores arcade_scores;
ArcadeScores::Score arcade_score;
std::string name_entry;
sb::Text scoreboard {fonts.at("large")}, thanks {fonts.at("medium")};
sb::Color rotating_hue;
std::vector<Flame> ending_coins;
std::vector<sb::Text> ending_messages;
std::optional<std::string> selected;
std::shared_ptr<SDL_GameController> controller = nullptr;
std::optional<float> pre_ad_volume = std::nullopt;
std::vector<Splash> splash;
sb::progress::Progress stat_progress;
sb::progress::Stats stats;
sb::progress::Achievements achievements;
std::optional<float> previous_distance;
sb::Texture increment_texture, decrement_texture;
sb::cloud::HTTP http;
/* Vector of coin sprite objects to be drawn to the screen simultaneously */
std::vector<sb::Sprite> bank_ui;
/*!
* Load sound effects and music into objects that can be used by the SDL mixer library. Use chunk objects for
* background music instead of music objects so background music tracks can fade into each other.
*/
void load_audio();
/*!
* Open configuration and load curve data into the object.
*/
void load_curves();
/*!
* Compile and attach shaders, store locations of uniforms, initialize some GL properties. This must be done after
* the GL context is created (currently the context is created in the Game constructor, so it will have been created
* already when this is called).
*/
void initialize_gl();
/*!
* Compile and link the programs defined in the "shaders" section of the configuration, and store them in
* Cakefoot::shader_programs. After loading, shaders can be activated by ID using Cakefoot::use_shader_program.
*
* This will delete any existing shaders before loading the new ones.
*/
void load_shaders();
/*!
* Wrapper around sb::shader::use which activates a shader program and assigns default values to uniforms. The
* program ID is looked up in Cakefoot::shader_programs by the name given.
*
* @param program Key of Cakefoot::shader_programs
*/
void use_shader_program(const std::string& name);
/*!
* Create button objects and assign them to the entries in the button map. This can be re-run to apply changes made
* in the configuration or to refresh label content.
*/
void set_up_buttons();
/*!
* Respond to a change in index in the challenge spinner.
*/
void toggle_challenge();
/*!
* Style the HUD elements based on the configuration settings. This can be re-run to apply changes made in the
* configuration.
*/
void set_up_hud();
/*!
* Style the diagnostic text overlays
*/
void set_up_diagnostic_display();
/*!
* Generate, bind, and fill a vertex buffer object with the game's vertex data.
*/
void load_vbo();
/*!
* Build a vector of coin sprites to display the player's bank in the UI.
*/
void populate_bank_ui();
/*!
* @return The current level's curve
*/
Curve& curve();
/*!
* @return The current level's curve
*/
const Curve& curve() const;
/*!
* Change the level to the given index. Load enemies, update the curve index.
*
* @param index index of the level to load
*/
void load_level(int index);
/*!
* Save the JSON in the `progress` field of sb::Game::configuration to storage at Cakefoot::progress_file_path.
*
* For PC builds, the folder `storage/` will be created in the current working directory if it doesn't exist
* already. The user must have permission to create folders in the directory. The file `cakefoot_progress.json` will
* also be created if necessary.
*
* For web builds, Emscripten is used to abstract this function so that writing to a browser's Indexed DB is done
* automatically. The folder `storage/` must have been mounted already using Emscripten's FS module.
*
* This function also syncs stats and session data to storage if certain flags are set in the Cakefoot object.
*
* If the boolean flag Cakefoot::save_stats is set, the stat progress object will be written to disk. If the
* boolean flag Cakefoot::sync_stats is set and the Steam API is initialized, the stats will also sync to Steam. If
* Cakefoot::sync_session is set, HTTP is initialized, and "session" > "receiver" > "url" is set, session data will
* be sent to the configured URL.
*
* @param force_save_stats Force stats to be written to file
* @param force_sync_stats Force stats to be synced with any active cloud storage
* @param force_sync_session Force player session data to be sent to the remote logger
*/
void write_progress(bool force_save_stats = false, bool force_sync_stats = false, bool force_sync_session = false);
/*!
* Stage a write operation using this object. Flip desired flags before running the write. When the write is
* triggered, the flags will automatically reset.
*/
ProgressWriter progress_writer {
std::bind(&Cakefoot::write_progress, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)
};
/*!
* Follows the same rules as Cakefoot::write_progress() to write arcade scores to a JSON file in
* `storage/cakefoot_arcade_scores.json`.
*
* @see Cakefoot::write_progress()
*/
void write_scores() const;
/*!
* @return Sum of the lengths of all curves on all regular levels.
*/
int length() const;
/*!
* @deprecated Use Cakefoot::distance_float instead, which will replace this function which has the unnecessary
* step of converting the distance to int.
*
* @return The character's current distance from the beginning of the first level.
*/
int distance() const;
/*!
* @return The character's current distance from the beginning of the first level as a float.
*/
float distance_float() const;
/*!
* @return The time limit of the current run. Combines the challenge mode's initial limit with added time for completed
* levels and checkpoints.
*/
float limit() const;
/*!
* @return True if arcade is the current mode
*/
bool arcade() const;
/*!
* @return True if quest is the current mode
*/
bool quest() const;
/*!
* @return True if level select is the current mode
*/
bool level_select() const;
/*!
* @param index optional level index
* @return True if level is currently the end screen
*/
bool end_screen(std::optional<std::size_t> index = std::nullopt) const;
/*!
* @return True if resume quest or resume arcade is the current mode
*/
bool resuming() const;
/*!
* The player's coins represented as a vector. Each entry corresponds with a level, excluding the title screen and
* ending screen. The entry is true if the player collected the coin for the level, or false otherwise.
*
* @return The player's coin status as a vector
*/
std::vector<bool> bank() const;
/*!
* Get the bank serialized as a string. If no bank is provided, read the serialized bank from the progress file.
*
* Each character in the string represents a level, excluding the title screen and ending screen. The characters
* representing collected and uncollected (for example, "+" and "-") are defined in the configuration.
*
* @return The player's coin status serialized as a string
*/
std::string bank_serialized(const std::vector<bool>& bank = {}) const;
/*!
* @return An initialized (empty) bank vector with every entry false, one entry per game level excluding title and
* end screens
*/
std::vector<bool> bank_init() const;
/*!
* @return Count of number of coins the player has collected
*/
std::size_t bank_count() const;
/*!
* @return Maximum number of coins that can be collected
*/
std::size_t max_bank() const;
/*!
* @param tokens Bank represented as a string
*
* @return Bank represented as an array of booleans
*/
std::vector<bool> bank_parse(const std::string& tokens) const;
inline bool skip_resume_quest()
{
return configuration()("challenge", challenge_index, "name") == "RESUME QUEST" &&
configuration()("progress", "quest level") == 1 && configuration()("progress", "quest checkpoint") == 0.0f;
}
inline bool skip_resume_arcade()
{
return configuration()("challenge", challenge_index, "name") == "RESUME ARCADE" &&
configuration()("progress", "arcade level") == 1 &&
configuration()("progress", "arcade checkpoint") == 0.0f;
}
inline bool skip_level_select()
{
return level_select() && configuration()("progress", "max difficulty") < 1 &&
configuration()("progress", "max level") <= 1;
}
/*!
* Remove coin from the enemy, the level, and the bankadd the coin to the appropriate bank.
*
* @param add_to_bank flag to either add to the bank or not
*/
void collect_coin();
/*!
* Move the level index to the end level, causing the game over state to end.
*/
void end_game_over_display();
/* This animation can be used to end the game over state after the time limit is reached. Play once with a delay to let the
* game over screen display temporarily before being ended by this animation. */
sb::Animation game_over_animation {std::bind(&Cakefoot::end_game_over_display, this)};
/*!
* Write score, refresh scoreboard, and load the title screen
*/
void submit_score();
/* Can be used to time out the name entry screen */
sb::Animation submit_score_animation {std::bind(&Cakefoot::submit_score, this)};
/*!
*/
void set_arcade_score(float extended_limit, int maximum_distance);
/*!
* Shift the hue of the global sb::Color object that tracks the hue shift by the given amount in the configuration.
*/
void shift_hue();
/* Shift the hue by the configured amount once per configured amount of seconds */
sb::Animation shift_hue_animation {std::bind(&Cakefoot::shift_hue, this)};
/* Flash the screen */
sb::Animation flash_animation;
/*!
* Toggle visibility of the flag used by the blink animation.
*/
void blink();
/* Toggle visibility flag every interval */
sb::Animation blink_animation {std::bind(&Cakefoot::blink, this), configuration()("display", "blink frequency")};
/* Count a cooldown period for gamepad axes */
sb::Animation cooldown_animation;
/*!
* Display next splash image.
*/
void next_splash();
/* Display splash images in succession until all splash images have been displayed. */
sb::Animation splash_animation {std::bind(&Cakefoot::next_splash, this)};
/*!
* Set arcade time limit warning state at Cakefoot::arcade_limit_warning based on mode, time remaining, and whether
* the blinking frame is on or off.
*/
void flash_warning();
/* Test whether or not the arcade time limit warning should be active. */
sb::Animation warning_animation {std::bind(&Cakefoot::flash_warning, this)};
/* Set booleans on a timer to indicate stat write and sync is needed. */
bool save_stats = false;
bool sync_stats = false;
bool sync_session = false;
sb::Animation save_stats_animation {[&](){ save_stats = true; }};
sb::Animation sync_stats_animation {[&](){ sync_stats = true; }};
sb::Animation sync_session_animation {[&](){ sync_session = true; }};
/*!
* Get the arcade time as the amount of time remaining before the limit is reached.
*
* @param limit Time limit of arcade mode
* @return Amount of time remaining in seconds
*/
float arcade_time_remaining(float limit) const;
/*!
* Convert an amount of seconds to MMM:SS.sss format. The amount of minute digits will be as many as necessary.
*
* @param amount Amount in seconds to convert
* @return String formatted to MMM:SS.sss
*/
static std::string format_clock(float amount);
/*!
* Build a texture displaying the top 25 scores and assign the texture to the scoreboard sprite.
*/
void refresh_scoreboard();
/*!
* Return the name of the button closest to a given button in Cakefoot::buttons in the specified direction ("up",
* "right", "down", or "left").
*
* @param subject The button to base the search on
* @param direction One of either "up", "right", "down", or "left"
* @param pool Buttons to search through. If omitted, all buttons will be searched.
* @return The nearest button in the specified direction or the subject if none is found
*/
std::string nearest_button(const std::string& subject, const std::string& direction,
const std::map<std::string, sb::Pad<>>& pool) const;
/*!
* @overload nearest_button(const std::string&, const std::string&, const std::map<std::string, sb::Pad<>>&)
*/
std::string nearest_button(const std::string& subject, const std::string& direction,
const std::vector<std::string>& names) const;
/*!
* @overload nearest_button(const std::string&, const std::string&, const std::map<std::string, sb::Pad<>>&)
*
* Searches every Cakefoot::button
*/
std::string nearest_button(const std::string& subject, const std::string& direction) const;
/*!
* Use to access data stored in the "progress" section of the game's configuration object. Intended to be replaced
* by sb::progress::Progress.
*
* @param key Progress data key
* @param fallback If no data is found, this optional value will be returned
*
* @return Value of the requested key in the progress data
*/
template<typename Type>
Type progress(const std::string& key, std::optional<Type> fallback = std::nullopt) const
{
if (fallback.has_value())
{
return configuration()("progress").value(key, fallback.value());
}
else
{
return configuration()("progress", key).get<Type>();
}
}
/*!
* @return The maximum index of the challenge list
*/
int max_challenge() const;
sb::Sprite achievements_background;
sb::Color achievements_background_color;
std::vector<sb::Sprite> achievements_text_sprites;
/*!
* Create new text sprite graphics for each achievement on the menu. Any existing graphics will be cleared.
*/
void load_achievements_menu();
/*!
* Draw all text sprite graphics and background for displaying the achievements menu.
*
* @param view Transformation from world space to camera space
* @param projection Transformation from camera space to clip space
* @param uniform List of uniforms available in the GLSL program
*/
void draw_achievements(
const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform) const;
sb::Sprite stats_sprite;
/*!
* Create a new texture with text displaying all the stats, replacing any existing graphics.
*/
void load_stats_menu();
/*!
* Draw the text sprite that displays the stats menu.
*
* @param view Transformation from world space to camera space
* @param projection Transformation from camera space to clip space
* @param uniform List of uniforms available in the GLSL program
*/
void draw_stats(
const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform) const;
std::string achievements_pop_up_text;
sb::Sprite achievements_pop_up_sprite;
sb::Animation achievements_pop_up_animation;
/*!
* Create a sprite for a pop-up showing the text stored in Cakefoot::achievements_pop_up_text. Existing graphics
* will be overwritten.
*/
void load_achievements_pop_up();
sb::Sprite confirmation_alert_label;
sb::Pad<> confirmation_confirm_button;
sb::Pad<> confirmation_cancel_button;
bool confirming_new_quest = false;
bool confirming_new_arcade = false;
/*!
* Create a sprite to display an alert message along with buttons for confirming or canceling.
*/
void load_confirmation_alert();
/*!
* Draw the confirmation alert dialog box
*
* @param view Transformation from world space to camera space
* @param projection Transformation from camera space to clip space
* @param uniform List of uniforms available in the GLSL program
*/
void draw_confirmation_alert(
const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform);
std::map<std::string, sb::Sprite> operator_menu_labels;
std::map<std::string, sb::Pad<>> operator_menu_buttons;
std::map<std::string, Textbox> operator_menu_textboxes;
bool operator_menu_edited = false;
int operator_menu_index_selected = 0;
bool operator_menu_confirming = false;
/*!
* Create UI objects for the operator menu.
*
* Set the preserve flag to keep the current state and contents of each widget. Otherwise, the state will be reset
* according to values in the "operator" section of the config. This flag can be used to preserve the state of
* checkboxes and text without changing the config value.
*
* @param preserve Keep the current state and contents of each widget
*/
void load_operator_menu(bool preserve = false);
/*!
* Draw the operator menu user interface.
*
* @param view Transformation from world space to camera space
* @param projection Transformation from camera space to clip space
* @param uniform List of uniforms available in the GLSL program
*/
void draw_operator_menu(
const glm::mat4& view, const glm::mat4& projection, const std::map<std::string, GLuint>& uniform);
#if defined(__LINUX__)
/* Update the game whenever this animation frame is triggered, only if the game is at the title screen. */
sb::Animation update_game_version_animation { [&](){
if (level_index == 0 && !new_version_downloaded) {
new_version_downloaded = update_game_version();
} } };
/* This flag is set if an update completes while the game is running */
bool new_version_downloaded = false;
/*!
*/
bool update_game_version();
#endif
protected:
/* Flag indicating whether or not to turn off collisions. Intended for automated testing of the game, using a mock
* Cakefoot class which inherits from this class to toggle the switch. The Cakefoot class itself does not turn
* this on in any of its functions. */
bool noclip = false;
/* Make this flag available to tests. */
bool operator_menu_active = false;
/*!
* Unlock the date-based achievements ACH_CAKE_MY_DAY and ACH_BIRTHDAY_CAKE if the given date is valid.
*
* @param date A std::chrono::time_point representing the date to be checked for achievement
*/
template<typename Clock>
void validate_date(std::chrono::time_point<Clock> time)
{
std::time_t ctime { std::chrono::system_clock::to_time_t(time) };
std::tm* tm { std::localtime(&ctime) };
bool unlocked = false;
if (tm->tm_mon == 10 && tm->tm_mday == 26)
{
sb::Log::Multi() << "It's International Cake Day! " << std::put_time(tm, "%c") << sb::Log::end;
stat_progress.unlock_achievement(achievements["ACH_CAKE_MY_DAY"]);
unlocked = true;
}
else if (tm->tm_mon == 4 && tm->tm_mday == 10)
{
sb::Log::Multi() << "It's Cakefoot's birthday! " << std::put_time(tm, "%c") << sb::Log::end;
stat_progress.unlock_achievement(achievements["ACH_BIRTHDAY_CAKE"]);
unlocked = true;
}
if (unlocked)
{
write_progress(true, true);
}
}
/*!
* Respond to a change in the configuration.
*/
void reconfig();
/*!
* Make the audio available to mocks for testing.
*/
std::map<std::string, sb::audio::Chunk> audio;
MultiBGM bgm;
/* Make credit count available to mocks for testing. */
float credits = 0.0f;
public:
/*!
* Initialize a Cakefoot instance, optionally with extra configuration to merge in after the engine and user configs
* are loaded.
*
* @param configuration_merge list of file paths with configuration JSON to merge in before the game and engine
* begins to load
*/
Cakefoot(std::vector<nlohmann::json> configuration_merge = {});
/*!
* @overload Cakefoot(std::vector<nlohmann::json>)
*/
Cakefoot(const nlohmann::json& configuration_merge) :
Cakefoot(std::vector<nlohmann::json>{configuration_merge}) {}
/*!
* Log detected joysticks and open the first one that is usable as a game controller
*/
void open_game_controller();
/*!
* Respond to command events
*/
void respond(SDL_Event&);
/*!
* @return true if the game is currently paused
*/
bool paused() const;
/*!
* Start timers, enable auto refresh, and run the super class's run function, which starts the game's update loop.
*
* @see sb::Game::run()
*/
void run(std::function<void(float)> draw, std::optional<std::function<void(float)>> update = std::nullopt);
/*!
* Update parameters and draw the screen.
*
* @param timestamp seconds since the start of the program
*/
void draw(float timestamp);
/*!
* Close the controller, then call sb::Game::quit()
*/
void quit();
};
#if defined(EMSCRIPTEN)
/*!
* This event handler should be registered with emscripten_set_visibilitychange_callback so it will be called
* automatically when the browser tab is hidden. A pause event will be posted through
* sb::Delegate::post(const std::string&, bool) when the browser tab is hidden.
*
* @param event_type Emscripten's ID for the visibilitychange event
* @param visibility_change_event Emscripten's event object
* @param user_data The game object passed as a void pointer through Emscripten's API
* @return True to indicate that the event was consumed by the handler
*/
EM_BOOL respond_to_visibility_change(int event_type, const EmscriptenVisibilityChangeEvent* visibility_change_event, void* user_data);
/*!
* This event handler should be registered with emscripten_set_gamepadconnected_callback so it will be called
* automatically when a gamepad is connected. If there isn't a gamepad already registered, the first detected gamepad
* will be registered as the game's controller.
*
* Even if a USB gamepad is connected when the program loads, the web browser may not register it as connected until a
* button is pressed, so this event will need to be triggered before any controller is usable on a web page.
*
* @param event_type Emscripten's ID for the gamepadconnected event
* @param visibility_change_event Emscripten's event object
* @param user_data The game object passed as a void pointer through Emscripten's API
* @return True to indicate that the event was consumed by the handler
*/
EM_BOOL respond_to_gamepad_connected(int event_type, const EmscriptenGamepadEvent* gamepad_event, void* user_data);
extern "C"
{
/*!
* Custom pause event for use with ad APIs that posts a "pause for ads" event using
* sb::Delegate::post(const std::string&, bool).
*
* This function will be exported for use in the JavaScript code in* a web build and is not available in other types
* of builds.
*/
void pause_for_ads();
/*!
* Custom pause event for use with ad APIs that posts a "unpause for ads" event using
* sb::Delegate::post(const std::string&, bool). This function will be exported for use in the JavaScript code in a
* web build and is not available in other types of builds.
*/
void unpause_for_ads();
}
#endif