spacebox/src/progress.hpp
Cocktail Frank a9437c0551 Keep a list of unlocked achievements in sb::progress
Add a function for syncing all stats and unlocked achievements to Steam
2024-12-22 13:09:06 -05:00

715 lines
28 KiB
C++

/* +=======================================================+
____/ \____ /: Open source game framework licensed to freely use, :
\ / / : copy, and modify - created for dank.game :
+==\ ^__^ /==+ : :
: ~/ \~ : : Download at https://open.shampoo.ooo/shampoo/spacebox :
: ~~~~~~~~~~~~ : +=======================================================+
: SPACE ~~~~~ : /
: ~~~~~~~ BOX :/
+=============*/
#pragma once
#include <optional>
#include <variant>
#if defined(__EMSCRIPTEN__)
#include <emscripten.h>
#include <emscripten/html5.h>
#endif
#include <json/json.hpp>
#include "sb.hpp"
#include "filesystem.hpp"
#include "Sprite.hpp"
#include "extension.hpp"
#include "cloud.hpp"
#include "Configuration.hpp"
namespace sb::progress
{
class Stat
{
public:
enum Type { INT, FLOAT };
private:
std::string _id;
std::string _name;
Type _type = INT;
bool _aggregated = true;
bool _increment_only = true;
std::vector<std::string> _sum;
std::optional<float> _max;
public:
/*!
* Create an object that represents a stat definition. This object just stores the definition, not the value of
* the stat. Use a progress object to store the value of a stat.
*
* See Stats for an explanation of the JSON format for a stat.
*
* @param id String containing only alphanumerics and the underscore character identifying the
* stat
* @param name A formatted string used to display the name of a stat
* @param type Either Type::INT or Type::FLOAT
* @param aggregated If true, the value will be added to a global count
* @param increment_only If true, the value can only be incremented, not decremented
* @param sum The value of this stat will be the sum of the given stats
* @param max The stat value will not be able to increase past this value
*/
Stat(const std::string& id, const std::string& name = "", Type type = INT, bool aggregated = true,
bool increment_only = true, const std::vector<std::string>& sum = {},
std::optional<float> max = std::nullopt);
/*!
* @return The string ID of the stat
*/
const std::string& id() const;
/*!
* @return The formatted name of the stat
*/
const std::string& name() const;
/*!
* @return Either Type::INT or Type::FLOAT
*/
Type type() const;
/*!
* @return True if a global sum of this stat should be tracked
*/
bool aggregated() const;
/*!
* @return True if the stat should not be allowed to decrease
*/
bool increment_only() const;
/*!
* @return A list of stats to sum to get the value of this stat
*/
const std::vector<std::string>& sum() const;
/*!
* @return Optional value that the stat should not be allowed to increase past
*/
std::optional<float> max() const;
};
template<typename Property>
class Properties
{
protected:
std::vector<Property> properties;
std::string key;
public:
Properties(const std::string& key) : key(key) {};
/*!
* @param id The property's string ID
*
* @return The corresponding property object
*
* @throw std::out_of_range if the ID is not found
*/
const Property& operator[](const std::string& id) const
{
for (const Property& property : properties)
{
if (property.id() == id)
{
return property;
}
}
throw(std::out_of_range(key + " ID " + id + " was not found"));
}
/*!
* @param index Index of the property
*
* @return The property object at the given index
*
* @throw std::out_of_range If the index does not exist
*/
const Property& operator[](int index) const
{
try
{
return properties.at(index);
}
catch (std::out_of_range)
{
std::ostringstream message;
message << key << " index " << index << " was not found";
throw(std::out_of_range(message.str()));
}
}
/*!
* @return A read-only vector containing all the properties
*/
operator const std::vector<Property>&() const
{
return properties;
}
typename std::vector<Property>::const_iterator begin() const
{
return properties.begin();
}
typename std::vector<Property>::const_iterator end() const
{
return properties.end();
}
};
class Stats : public Properties<Stat>
{
public:
/*!
* Load the stat definitions in a given configuration into this object. The configuration must have a "stats"
* key, which must contain an array, and each member of the array must be a dict formatted like the following
* example.
*
* @code{.json}
* {
* "id": "STAT_BACON_ATTEMPTS",
* "name": "Bacon attempts",
* "type": "INT",
* "sum": ["STAT_BACON_STOLEN", "STAT_BACON_LOST"],
* "aggregated": true,
* "increment only": true
* }
* @endcode
*
* The "type" field can be either "INT" or "FLOAT". The default is "INT".
*
* The "sum" field is optional. If "sum" is given, the stat will automatically be set to the sum of the other
* stats.
*
* The "aggregated" field indicates whether the stat value will be tracked globally. The global value will be
* the sum of the local values. This is currently only implemented for the Steam API. The field will be set to
* true by default.
*
* The "increment only" field indicates whether the value can only be added to and not subtracted from. It is
* set to true by default.
*
* The "max" field is optional. If it is set, the stat value will be prevented from incrementing past the max.
* If not set, there is no max value for the stat.
*
* The format is based on Steam's achievement and stats format.
*
* See https://partner.steamgames.com/doc/features/achievements for more information.
*
* @param config JSON object that contains a "stats" field with the stat definitions.
*/
Stats(const nlohmann::json& config);
/*!
* @overload Stats::Stats(const nlohmann::json&)
*/
Stats(const sb::Configuration& config) : Stats(config()) {};
};
class Progress;
/*!
* Pass an unlocker object to the Achievement constructor to associate a stat object and unlock amount with the
* achievement. When the given stat reaches the given unlock amount, the achievement automatically unlocks.
*/
struct Unlocker
{
std::string stat_id;
float goal;
};
class Achievement
{
private:
std::string _id;
nlohmann::json _json;
std::string _name;
std::string _description;
sb::Sprite _locked_sprite;
sb::Sprite _unlocked_sprite;
std::optional<Unlocker> unlocker;
public:
/*!
* Create an object that represents an achievement definition. This object just stores the definition, not the
* state of whether or not it is unlocked. Use a progress object to store the state of an acheivement.
*/
Achievement(const std::string& id, const nlohmann::json& json = {}, const std::string& name = "",
const std::string& description = "", const sb::Sprite& locked_sprite = sb::Sprite(),
const sb::Sprite& unlocked_sprite = sb::Sprite(), std::optional<Unlocker> unlocker = std::nullopt);
/*!
* @return The name of the achievement
*/
const std::string& name() const;
/*!
* @return The string ID of the achievement
*/
const std::string& id() const;
/*!
* @return The description of the achievement
*/
const std::string& description() const;
/*!
* @return Sprite for displaying the texture representing the achievement when not unlocked yet
*/
const sb::Sprite& locked_sprite() const;
/*!
* @return Sprite for displaying the texture representing the achievement after being unlocked
*/
const sb::Sprite& unlocked_sprite() const;
/*!
* @param stat Check if this stat object is the correct unlocker stat
* @param value Check if the value is high enough to reach the unlocker value
*
* @return True if the given stat is needed to unlock this achievement and the value is the necessary amount
*/
bool unlocks(const Stat& stat, float value) const;
/*!
* @return Read-only access to the raw JSON object, can be used to access custom fields
*/
const nlohmann::json& json() const;
};
class Achievements : public Properties<Achievement>
{
public:
/*!
* Load the achievement definitions in a given configuration into this object. The configuration must have an
* "achievements" key, which must contain an array, and each member of the array must be a dict formatted like
* the following example.
*
* @code{.json}
* {
* "id": "ACH_BACON_BEAST",
* "name": "Bacon beast",
* "description": "Steal the bacon 2077 times",
* "image":
* {
* "locked": "resource/achievements/bacon_beast_locked.png",
* "unlocked": "resource/achievements/bacon_beast_unlocked.png"
* },
* "stat":
* {
* "id": "STAT_BACON_STOLEN",
* "unlock": 2077
* }
* }
* @endcode
*
* The "stat" field is optional. It indicates a stat that will automatically unlock this achievement when the
* given unlock value is reached.
*
* The format is based on Steam's achievement and stats format.
*
* See https://partner.steamgames.com/doc/features/achievements for more information.
*
* @param config JSON object that contains an "achievements" field containing achievement definitions
*/
Achievements(const nlohmann::json& config);
/*!
* @overload Achievements(const nlohmann::json&)
*/
Achievements(const sb::Configuration& config) : Achievements(config()) {};
};
class Progress
{
private:
const std::string stats_key {"stats"};
const std::string achievements_key {"achievements"};
const std::string config_key {"config"};
/* Serializes to JSON */
nlohmann::json contents {
{achievements_key, nlohmann::json::array()},
{stats_key, nlohmann::json::object()},
{config_key, nlohmann::json::object()}
};
/* Track whether there are unsaved changes */
bool unsaved = false;
/* Track unlocked achievements */
std::vector<std::string> unlocked;
/*!
* Set an achievement's state to unlocked in this progress object for every achievement in the given list that
* the given stat unlock.
*
* @param stat Stat which may unlock one of the given achievements
* @param value Stat value which may unlock one of the given achievements
* @param achievements List to check for achievements which should be unlocked
*/
void unlock_associated_achievements(
const Stat& stat, float value, const std::vector<Achievement>& achievements);
/*!
* Set a value, overriding the check for reserved values.
*
* @see set(const Data& value, const Key& key, const Keys&... keys)
*/
template<typename Data, typename Key, typename... Keys>
void _set(const Data& value, const Key& key, const Keys&... keys)
{
std::ostringstream hierarchy;
json_access(hierarchy, contents, true, key, keys...) = value;
unsaved = true;
}
#if defined(STEAM_ENABLED)
/*!
* Call Steam's SetStat for the given stat. sb::cloud::steam::store_stats() must be called afterward.
*
* If the given stat is defined as a sum of other stats, the `associates` parameter must be provided. The
* `associates` will be used to find the stats which comprise the sum defined by the given stat.
*
* @param stat A stat to be stored on Steam's server
* @param associates Other stats used to determine the sum if necessary
*/
void set_steam_stat(const Stat& stat, const std::vector<Stat>& associates = {}) const;
/*!
* Call Steam's SetAchievement for the given achievement. sb::cloud::steam::store_stats() must be called
* afterward, although Progress::unlock_achievement should handle that automatically when an achievement is
* first unlocked.
*
* @param achievement An achievement to unlock on Steam
*/
void unlock_steam_achievement(const Achievement& achievement) const;
#endif
public:
/*!
* Open the given file of JSON progress data and read its contents into this object. Any existing progress in
* this object will be overwritten.
*
* If the `defaults` parameter is set, the progress will first be loaded with the given default definitions.
* Then the given path will be merged into the defaults.
*
* Defaults cannot be defined for "stats", "achievements", or "config". Those internally default to empty. If
* any are defined, an error will be thrown.
*
* @param path Path to a progress file, which must be in JSON format
* @param default Optional initial progress definitions which will be overwritten by the file being loaded
*
* @throw sb::ReservedError If a key in the defaults is reserved for special data
*/
void load(const fs::path& path, const nlohmann::json& defaults = {});
/*!
* Load progress from a JSON object
*
* @overload load(fs::path&)
*
* @param contents JSON that contains progress data
* @param default Optional initial progress definitions which will be overwritten by definitions in the
* contents parameter
*/
void load(const nlohmann::json& contents, const nlohmann::json& defaults = {});
/*!
* Serialize the progress data to JSON and write to a given path. The resulting JSON will be able to be reloaded
* into a progress object.
*
* If `force` is not set and there are no unsaved changes, the function will not do anything.
*
* If `force` is set, attempt to save even if there are no unsaved changes. This could be useful, for example,
* if the filesystem sync with Emscripten failed and the function needs to be forced to run again, or if writing
* the same progress to multiple files.
*
* @param path JSON progress data will be written to this path
* @param force If set, progress will be saved even if there are no unsaved changes
*/
void save(fs::path path, bool force = false);
/*!
* Store that the given achievement is unlocked. After this is called, Progress::achievement_unlocked will
* return true when it is called with the same achievement object.
*
* If the achievement has an associated unlocker stat (See Unlocker and Achievement), the achievement will not
* be unlocked. An achievement with an unlocker will automatically unlock when the associated stat reaches the
* unlock amount.
*
* If the achievement was previously unlocked, and Steam is enabled, sb::cloud::steam::store_stats() will be
* called.
*
* @param achievement Achievement object representing the achievement to store as unlocked
*/
void unlock_achievement(const Achievement& achievement);
/*!
* @return True if the achievement has been registered as unlocked in this progress object, False otherwise
*/
bool achievement_unlocked(const Achievement& achievement) const;
/*!
* @return The number of unlocked achievements recorded by this progress object
*/
int achievement_count() const;
/*!
* Get a list of achievement IDs that have been unlocked while the program has been running, since the last time
* the list was cleared, in ascending order by time unlocked.
*
* The IDs can be used to access the corresponding achievement object in the associated Achievements object.
*
* This can be useful for running code when a achievement unlocks. Check this list every update for new
* additions, leaving the `clear` parameter set. If there is an achievement in the list, that means an
* achievement was unlocked since the last time the list was checked.
*
* @param clear Set true to clear the list upon reading
*
* @return A list of IDs unlocked since the last time the list was cleared
*/
const std::vector<std::string>& live_achievements(bool clear = false);
/*!
* @param stat Check if this stat ID is the correct unlocker stat
* @param achievement Achievement to test for unlock
*
* @return True if the given stat is needed to unlock the achievement and the stat has the necessary amount
*/
bool unlocks(const Stat& stat, const Achievement& achievement) const;
/*!
* @param stat Stat to check
* @return True if the given stat is already being tracked by this progress object
*/
bool stat_exists(const Stat& stat) const;
/*!
* @param stat Stat to add to this progress object so it can be tracked
*/
void add_stat(const Stat& stat);
/*!
* Increment the stat value by the given amount and store the resulting value in this progress object.
*
* The amount can be either an int or float. If the stat's type is "INT", the result will be rounded.
*
* A negative value can be given in order to decrement the stat value, but if the stat's "increment only"
* property is set to true, the function will not do anything.
*
* If the given stat has a "max" property set, the stat value will not be incremented past the "max" value.
*
* If the program has been compiled with Steam API support, this will attempt to set the stat via the Steam
* API's SetStat function. To store the stat on Steam's servers, the sb::cloud::steam::store_stats() function
* needs to be run afterward.
*
* For description of `associated_achievements` and `associated_stats`, see sb::progress::Progress::set_stat.
*
* @see sb::progress::Progress::set_stat
*
* @param stat A statistic to update
* @param amount Amount to increment (or decrement if negative)
* @param associated_achievements List of achievement definitions to check for automatic unlocking
* @param associated_stats List of stats to check for stats which are the sum of other stats which
* need to be updated on Steam
*/
void increment_stat(
const Stat& stat, float amount = 1.0f, const std::vector<Achievement>& associated_achievements = {},
const std::vector<Stat>& associated_stats = {});
/*!
* Set the value of the given stat to the given value and store the value in this progress object.
*
* The amount can be either an int or float. If the stat's type is "INT", the value will rounded.
*
* The value given can be any negative or positive number, but if the stat's "increment only" or "max"
* properties are set and the value is too low or high, the value will not be set.
*
* If the program has been compiled with Steam API support, this will attempt to sync the stat with the
* currently active Steam account.
*
* Note that if the given stat object defines the stat to be the sum of other stats, this function will not set
* the value of the stat and will log a warning.
*
* If the optional `associated_achievements` parameter is set to a list of Achievement objects, the list will be
* checked for any achievements which are unlocked by the stat being incremented. If the stat value is greater
* than or equal to the unlock value defined in the achievement, the achievement will be automatically unlocked.
*
* If the optional `associated_stats` parameter is set to a list of Stat objects, the list will be checked for
* any stats which are defined to be a sum of other stats, one of which being the given stat. If any stats are
* found, they will be updated on Steam because Steam does not automatically calculate sums. Note that this
* list should include the same stat being updated.
*
* @param stat A statistic to update
* @param value The new value for the stat
* @param associated_achievements List of achievement definitions to check for automatic unlocking
* @param associated_stats List of stats to check for stats which are the sum of other stats which
* need to be updated on Steam
*/
void set_stat(const Stat& stat, float value, const std::vector<Achievement>& associated_achievements = {},
const std::vector<Stat>& associated_stats = {});
/*!
* Get the value of a given stat.
*
* If the stat is defined as a sum of other stats, the `associates` parameter must be provided. The `associates`
* are other stats to use to determine the sum. All associate stats that comprise the sum of the given stat must
* be present in `associates`, even if they don't have a value set in this progress object. If an associated
* stat needed for the sum does not have a value in this progress object, it will be counted as zero.
*
* @param stat Stat to get a value for
* @param associates Other stats used to determine the sum if necessary
*
* @return The value stored for the stat
*
* @throw std::out_of_range If the given stat is a sum and the necessary stats to form the sum are not in
* associates
* @throw std::runtime_error If the key is not accessible
*/
float stat_value(const Stat& stat, const std::vector<Stat>& associates = {}) const;
/*!
* Get the value of a given stat. If the stat is not contained in the progress object, return the given default
* value instead. Otherwise, behaves like Progress::stat_value.
*
* @see stat_value(const Stat&, const std::vector<Stat>&)
*
* @param stat Stat to get a value for
* @param default_value Return this value if the stat does not exist
* @param associates Other stats used to determine the sum if necessary
*
* @return The value stored for the stat or the given default value if the stat doesn't exist
*/
float stat_default(const Stat& stat, float default_value, const std::vector<Stat>& associates = {}) const;
#if defined(STEAM_ENABLED)
/*!
* For all unlocked achievements and set stats in the progress object, call unlock and set on Steam. If a stat
* is part of a sum stat, the corresponding sum stat on Steam will be set.
*/
void sync_to_steam(const Achievements& achievements, const Stats& stats) const;
#endif
/*!
* Store the given JSON in the config section of this progress object. This can be used to store config
* overrides per user to merge back into a game's configuration when the progress is loaded.
*
* @param json JSON object containing configuration data
*/
void config(const nlohmann::json& json);
/*!
* Merge the config overrides in this progress object into the given configuration object.
*
* @param config Configuration object to override
*/
void merge(Configuration& config) const;
/*!
* @return A list of reserved keys in the progress's JSON representation. These cannot be used as the name of a
* new progress field.
*/
const std::vector<std::string> reserved() const;
/*!
* Set the given key to the given value. The key can be a hierarchy of keys. Any non-existing keys higher than
* the leaf key will be added to the progress.
*
* @param value Value to set the given progress key to
* @param key Top level key to lookup
* @param keys... Further levels of keys
*
* @throw sb::ReservedError If the key is reserved for special data in the progress
*/
template<typename Data, typename Key, typename... Keys>
void set(const Data& value, const Key& key, const Keys&... keys)
{
/* Check if the top level key is a reserved name. */
for (const std::string& name : reserved())
{
if (key == name)
{
std::ostringstream message;
message << name << " cannot be stored directly because it is a reserved name. " <<
"Use Progress::config, Progress::set_stat, or Progress::unlock_achievement.";
throw ReservedError(message.str());
}
}
/* Set value and mark object as unsaved */
_set(value, key, keys...);
}
/*!
* Set a value, specifically in the config section of this progress object.
*
* @overload set(const Data& value, const Key& key, const Keys&... keys)
*
* @see config(const nlohmann::json& json)
*/
template<typename Data, typename Key, typename... Keys>
void config(const Data& value, const Key& key, const Keys&... keys)
{
/* Set value and mark object as unsaved */
_set(value, config_key, key, keys...);
}
/*!
* Read-only access to a value previously set in the progress.
*
* @param key Top level key to lookup
* @param keys... Further levels of keys
*
* @return Value at the given key hierarchy as a JSON object
*
* @throw std::runtime_error is the key is not accessible
*/
template<typename Key, typename... Keys>
const nlohmann::json& read(const Key& key, const Keys&... keys) const
{
std::ostringstream hierarchy;
return json_access(hierarchy, contents, false, key, keys...);
}
/*!
* @return True if JSON contents of each progress object are equivalent
*/
bool operator==(const Progress& other) const;
};
}