diff --git a/src/object/ghost_replay.cpp b/src/object/ghost_replay.cpp new file mode 100644 index 0000000000..236952b62c --- /dev/null +++ b/src/object/ghost_replay.cpp @@ -0,0 +1,160 @@ +// SuperTux +// Copyright (C) 2026 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#include "object/ghost_replay.hpp" + +#include +#include + +#include "math/rectf.hpp" +#include "sprite/sprite.hpp" +#include "sprite/sprite_manager.hpp" +#include "supertux/constants.hpp" +#include "video/color.hpp" +#include "video/drawing_context.hpp" + +namespace { + +// Universal Tux action used as initial sprite state for ghost replay +const char* const BOOTSTRAP_ACTION = "small-stand-right"; + +} // namespace + +GhostReplay::GhostReplay(std::vector path) : + m_path(std::move(path)), + m_time(0.0f), + m_position(), + m_sprite(), + m_current_action() +{ + if (!m_path.empty()) + m_position = m_path.front().position; + + m_sprite = SpriteManager::current()->create("images/creatures/tux/tux.sprite"); + + if (m_sprite && m_sprite->load_successful()) + { + m_sprite->set_alpha(0.65f); + m_sprite->set_color(Color(0.6f, 0.9f, 1.0f, 1.0f)); + + if (m_sprite->has_action(BOOTSTRAP_ACTION)) + { + m_sprite->set_action(BOOTSTRAP_ACTION, -1); + m_current_action = BOOTSTRAP_ACTION; + } + + if (!m_path.empty()) + apply_action(m_path.front().action); + } +} + +void +GhostReplay::update(float dt_sec) +{ + if (m_path.empty()) + return; + + const float end_time = m_path.back().time; + float new_time = m_time + dt_sec; + if (new_time > end_time) + new_time = end_time; + + m_position = sample_position(new_time); + apply_action(sample_action(new_time)); + m_time = new_time; +} + +void +GhostReplay::draw(DrawingContext& context) +{ + if (m_path.empty()) + return; + + if (m_sprite && m_sprite->load_successful()) + { + m_sprite->draw(context.color(), m_position, LAYER_OBJECTS + 2); + } + else + { + const float ghost_size = 16.0f; + const Vector ghost_pos = m_position + Vector(-ghost_size * 0.5f, -ghost_size * 0.5f); + const Rectf rect(ghost_pos, Vector(ghost_size, ghost_size)); + context.color().draw_filled_rect(rect, Color(0.2f, 0.8f, 1.0f, 0.45f), LAYER_OBJECTS + 2); + } +} + +Vector +GhostReplay::sample_position(float time) const +{ + assert(!m_path.empty()); + + if (time <= m_path.front().time) + return m_path.front().position; + + if (time >= m_path.back().time) + return m_path.back().position; + + const auto it = std::upper_bound(m_path.begin(), m_path.end(), time, + [](float value, const worldmap::LevelTile::GhostRunPoint& point) { + return value < point.time; + }); + + if (it == m_path.end()) + return m_path.back().position; + + const auto& next = *it; + const auto& previous = *(it - 1); + const float segment_duration = next.time - previous.time; + const float segment_progress = segment_duration > 0.0f ? (time - previous.time) / segment_duration : 0.0f; + return previous.position + (next.position - previous.position) * segment_progress; +} + +const std::string& +GhostReplay::sample_action(float time) const +{ + assert(!m_path.empty()); + + if (time <= m_path.front().time) + return m_path.front().action; + + if (time >= m_path.back().time) + return m_path.back().action; + + const auto it = std::upper_bound( + m_path.begin(), + m_path.end(), + time, + []( + float value, const worldmap::LevelTile::GhostRunPoint& point) { + return value < point.time; + }); + + return (it == m_path.begin() ? *it : *(it - 1)).action; +} + +void +GhostReplay::apply_action(const std::string& action) +{ + if (action == m_current_action) + return; + if (!m_sprite || !m_sprite->load_successful()) + return; + if (!m_sprite->has_action(action)) + return; + + m_sprite->set_action(action, -1); + m_current_action = action; +} diff --git a/src/object/ghost_replay.hpp b/src/object/ghost_replay.hpp new file mode 100644 index 0000000000..0d044a9b6c --- /dev/null +++ b/src/object/ghost_replay.hpp @@ -0,0 +1,56 @@ +// SuperTux +// Copyright (C) 2026 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#pragma once + +#include "supertux/game_object.hpp" + +#include +#include + +#include "math/vector.hpp" +#include "sprite/sprite_ptr.hpp" +#include "worldmap/level_tile.hpp" + +class DrawingContext; + +/** Cosmetic Tux ghost that replays the player's best run for the current + level. Does not interact with physics, enemies, or any world objects. */ +class GhostReplay final : public GameObject +{ +public: + explicit GhostReplay(std::vector path); + + virtual GameObjectClasses get_class_types() const override + { + return GameObject::get_class_types().add(typeid(GhostReplay)); + } + + virtual void update(float dt_sec) override; + virtual void draw(DrawingContext& context) override; + virtual bool is_saveable() const override { return false; } + +private: + Vector sample_position(float time) const; + const std::string& sample_action(float time) const; + void apply_action(const std::string& action); + + std::vector m_path; + float m_time; + Vector m_position; + SpritePtr m_sprite; + std::string m_current_action; +}; diff --git a/src/supertux/game_session.cpp b/src/supertux/game_session.cpp index a201cab43d..f7277047de 100644 --- a/src/supertux/game_session.cpp +++ b/src/supertux/game_session.cpp @@ -17,6 +17,7 @@ #include "supertux/game_session.hpp" #include +#include #include #include @@ -29,6 +30,7 @@ #include "object/camera.hpp" #include "object/endsequence_fireworks.hpp" #include "object/endsequence_walk.hpp" +#include "object/ghost_replay.hpp" #include "object/level_time.hpp" #include "object/music_object.hpp" #include "object/player.hpp" @@ -62,7 +64,7 @@ static const float TELEPORT_FADE_TIME = 1.0f; static const float TELEPORT_FADE_TIME_CIRCLE = 1.43f; static const float TELEPORT_SPEEDUP = 3.18f; -GameSession::GameSession(Savegame* savegame, Statistics* statistics) : +GameSession::GameSession(Savegame* savegame, Statistics* statistics, const std::vector& best_ghost_run) : reset_button(false), reset_checkpoint_button(false), m_prevent_death(false), @@ -94,6 +96,8 @@ GameSession::GameSession(Savegame* savegame, Statistics* statistics) : m_active(false), m_end_seq_started(false), m_pause_target_timer(false), + m_current_run_path(), + m_best_ghost_run(best_ghost_run), m_current_cutscene_text(), m_endsequence_timer() { @@ -106,20 +110,20 @@ GameSession::GameSession(Savegame* savegame, Statistics* statistics) : } -GameSession::GameSession(Level* level, Savegame* savegame, Statistics* statistics) : - GameSession{savegame, statistics} +GameSession::GameSession(Level* level, Savegame* savegame, Statistics* statistics, const std::vector& best_ghost_run) : + GameSession{savegame, statistics, best_ghost_run} { m_level = level; } -GameSession::GameSession(const std::string& levelfile_, Savegame& savegame, Statistics* statistics) : - GameSession{&savegame, statistics} +GameSession::GameSession(const std::string& levelfile_, Savegame& savegame, Statistics* statistics, const std::vector& best_ghost_run) : + GameSession{&savegame, statistics, best_ghost_run} { m_levelfile = levelfile_; } -GameSession::GameSession(std::istream& istream_, Savegame* savegame, Statistics* statistics) : - GameSession{savegame, statistics} +GameSession::GameSession(std::istream& istream_, Savegame* savegame, Statistics* statistics, const std::vector& best_ghost_run) : + GameSession{savegame, statistics, best_ghost_run} { m_levelstream = &istream_; } @@ -292,6 +296,7 @@ GameSession::restart_level(bool after_death, bool preserve_music) m_play_time = 0; // Reset play time. m_data_table.clear(); + m_current_run_path.clear(); } /* Perform the respawn from the chosen spawnpoint. */ @@ -310,6 +315,12 @@ GameSession::restart_level(bool after_death, bool preserve_music) { m_currentsector->activate(spawnpoint->spawnpoint); } + + if (g_config->show_speedrun_ghost_mode && !m_best_ghost_run.empty()) + { + m_currentsector->add(m_best_ghost_run); + m_currentsector->flush_game_objects(); + } } catch (std::exception& e) { throw std::runtime_error(std::string("Couldn't start level: ") + e.what()); @@ -539,7 +550,7 @@ GameSession::draw(Compositor& compositor) if (m_game_pause) draw_pause(context); - if (g_config->show_game_timer && !g_debug.hide_player_hud) + if ((g_config->show_game_timer || g_config->show_speedrun_ghost_mode) && !g_debug.hide_player_hud) draw_timer(context); } @@ -653,6 +664,12 @@ GameSession::update(float dt_sec, const Controller& controller) m_currentsector = sector; m_currentsector->play_looping_sounds(); + if (g_config->show_speedrun_ghost_mode && !m_best_ghost_run.empty()) + { + m_currentsector->add(m_best_ghost_run); + m_currentsector->flush_game_objects(); + } + switch (m_spawn_fade_type) { case ScreenFade::FadeType::FADE: @@ -714,6 +731,11 @@ GameSession::update(float dt_sec, const Controller& controller) } m_currentsector->update(dt_sec); + if (!players.empty() && players.front()->is_active()) + { + const Player& player = *players.front(); + m_current_run_path.push_back({0.0f, player.get_pos(), player.get_action()}); + } } else { bool are_all_stopped = true; @@ -814,7 +836,7 @@ GameSession::finish(bool win) if (win) { if (WorldMapSector::current()) { - WorldMapSector::current()->finished_level(m_level); + WorldMapSector::current()->finished_level(m_level, m_current_run_path); } if (LevelsetScreen::current()) @@ -1068,9 +1090,62 @@ GameSession::draw_timer(DrawingContext& context) const << std::setw(2) << s << '.' << std::setw(3) << ms; - context.color().draw_text(Resources::normal_bitmap_font, out.str(), - Vector(context.get_width() / 2 - + (m_currentsector->get_object_count() > 0 ? context.get_width() * 0.10f : 0), - 20.f), - ALIGN_CENTER, LAYER_HUD); + const std::string text = out.str(); + const float x = context.get_width() / 2 + + (m_currentsector->get_object_count() > 0 ? context.get_width() * 0.10f : 0); + const float y = 20.f; + + const auto players = m_currentsector->get_players(); + + if (g_config->show_speedrun_ghost_mode && !m_best_ghost_run.empty() && !players.empty()) + { + const float player_x = players.front()->get_pos().x; + + // Find the time at which the ghost first reached the player's current x + float ghost_time_at_player_x = m_best_ghost_run.back().time; + for (const auto& point : m_best_ghost_run) + { + if (point.position.x >= player_x) + { + ghost_time_at_player_x = point.time; + break; + } + } + + const float delta = m_play_time - ghost_time_at_player_x; + const bool behind = delta >= 0.0f; + const Color pace_color = behind ? Color::RED : Color::GREEN; + + int delta_ms = static_cast(std::abs(delta) * 1000.f); + const int delta_m = delta_ms / (1000 * 60); + delta_ms -= delta_m * (1000 * 60); + const int delta_s = delta_ms / 1000; + delta_ms -= delta_s * 1000; + + std::ostringstream delta_out; + delta_out << (behind ? '+' : '-') << std::setfill('0') + << std::setw(2) << delta_m << ':' + << std::setw(2) << delta_s << '.' + << std::setw(3) << delta_ms; + + context.color().draw_text(Resources::normal_bitmap_font, + text, + Vector(x, y), + ALIGN_CENTER, + LAYER_HUD, + pace_color + ); + + context.color().draw_text(Resources::normal_bitmap_font, + delta_out.str(), + Vector(x, y + Resources::normal_bitmap_font->get_height()), + ALIGN_CENTER, + LAYER_HUD, + Color::WHITE + ); + } + else + { + context.color().draw_text(Resources::normal_bitmap_font, text, Vector(x, y), ALIGN_CENTER, LAYER_HUD); + } } diff --git a/src/supertux/game_session.hpp b/src/supertux/game_session.hpp index 2303e52f95..7aac45d25d 100644 --- a/src/supertux/game_session.hpp +++ b/src/supertux/game_session.hpp @@ -38,6 +38,7 @@ #include "supertux/timer.hpp" #include "supertux/level.hpp" #include "video/surface_ptr.hpp" +#include "worldmap/level_tile.hpp" class CodeController; class DrawingContext; @@ -79,10 +80,22 @@ class GameSession final : public Screen, }; public: - GameSession(Savegame* savegame = nullptr, Statistics* statistics = nullptr); - GameSession(Level* level, Savegame* savegame = nullptr, Statistics* statistics = nullptr); - GameSession(const std::string& levelfile, Savegame& savegame, Statistics* statistics = nullptr); - GameSession(std::istream& istream, Savegame* savegame = nullptr, Statistics* statistics = nullptr); + GameSession(Savegame* savegame = nullptr, + Statistics* statistics = nullptr, + const std::vector& best_ghost_run = {} + ); + GameSession(Level* level, Savegame* savegame = nullptr, + Statistics* statistics = nullptr, + const std::vector& best_ghost_run = {} + ); + GameSession(const std::string& levelfile, Savegame& savegame, + Statistics* statistics = nullptr, + const std::vector& best_ghost_run = {} + ); + GameSession(std::istream& istream, Savegame* savegame = nullptr, + Statistics* statistics = nullptr, + const std::vector& best_ghost_run = {} + ); virtual void draw(Compositor& compositor) override; virtual void update(float dt_sec, const Controller& controller) override; @@ -206,6 +219,7 @@ class GameSession final : public Screen, float m_play_time; /**< total time in seconds that this session ran interactively */ bool m_levelintro_shown; /**< true if the LevelIntro screen was already shown */ + std::vector m_best_ghost_run; bool m_skip_intro; /**< Manually skipped the intro from outside this class */ int m_coins_at_start; /** How many coins does the player have at the start */ @@ -217,6 +231,7 @@ class GameSession final : public Screen, bool m_end_seq_started; bool m_pause_target_timer; + std::vector m_current_run_path; std::unique_ptr m_current_cutscene_text; Timer m_endsequence_timer; diff --git a/src/supertux/gameconfig.cpp b/src/supertux/gameconfig.cpp index d3b5bace5b..ca208d10a5 100644 --- a/src/supertux/gameconfig.cpp +++ b/src/supertux/gameconfig.cpp @@ -60,6 +60,7 @@ Config::Config() : show_player_pos(false), show_controller(false), show_game_timer(false), + show_speedrun_ghost_mode(false), camera_peek_multiplier(0.03f), sound_enabled(true), music_enabled(true), @@ -177,6 +178,7 @@ Config::load() config_mapping.get("show_player_pos", show_player_pos); config_mapping.get("show_controller", show_controller); config_mapping.get("show_game_timer", show_game_timer); + config_mapping.get("show_speedrun_ghost_mode", show_speedrun_ghost_mode); config_mapping.get("camera_peek_multiplier", camera_peek_multiplier); config_mapping.get("developer", developer_mode); config_mapping.get("confirmation_dialog", confirmation_dialog); @@ -439,6 +441,7 @@ Config::save() writer.write("show_player_pos", show_player_pos); writer.write("show_controller", show_controller); writer.write("show_game_timer", show_game_timer); + writer.write("show_speedrun_ghost_mode", show_speedrun_ghost_mode); writer.write("camera_peek_multiplier", camera_peek_multiplier); writer.write("developer", developer_mode); writer.write("confirmation_dialog", confirmation_dialog); diff --git a/src/supertux/gameconfig.hpp b/src/supertux/gameconfig.hpp index 4f71a7a1fe..646df100df 100644 --- a/src/supertux/gameconfig.hpp +++ b/src/supertux/gameconfig.hpp @@ -78,6 +78,7 @@ class Config final bool show_player_pos; bool show_controller; bool show_game_timer; + bool show_speedrun_ghost_mode; float camera_peek_multiplier; bool sound_enabled; bool music_enabled; diff --git a/src/supertux/menu/options_menu.cpp b/src/supertux/menu/options_menu.cpp index bbc6b85559..14e85d6fc8 100644 --- a/src/supertux/menu/options_menu.cpp +++ b/src/supertux/menu/options_menu.cpp @@ -214,6 +214,9 @@ OptionsMenu::refresh() add_toggle(MNID_SHOW_GAME_TIMER, _("Show game timer"), &g_config->show_game_timer) .set_help(_("Show a game timer while playing a level")); + add_toggle(MNID_SPEEDRUN_GHOST_MODE, _("Speedrun ghost mode"), &g_config->show_speedrun_ghost_mode) + .set_help(_("Enable ghost mode behavior for speedruns.")); + add_toggle(MNID_CUSTOM_TITLE_LEVELS, _("Custom title screen levels"), &g_config->custom_title_levels) .set_help(_("Allow overriding the title screen level, when loading certain worlds")); diff --git a/src/supertux/menu/options_menu.hpp b/src/supertux/menu/options_menu.hpp index dbd18384c8..a1128de043 100644 --- a/src/supertux/menu/options_menu.hpp +++ b/src/supertux/menu/options_menu.hpp @@ -80,6 +80,7 @@ class OptionsMenu final : public Menu MNID_CHRISTMAS_MODE, MNID_TRANSITIONS, MNID_SHOW_GAME_TIMER, + MNID_SPEEDRUN_GHOST_MODE, MNID_CUSTOM_TITLE_LEVELS, MNID_CONFIRMATION_DIALOG, MNID_PAUSE_ON_FOCUSLOSS, diff --git a/src/worldmap/level_tile.cpp b/src/worldmap/level_tile.cpp index 516368beab..0bb8b66ef8 100644 --- a/src/worldmap/level_tile.cpp +++ b/src/worldmap/level_tile.cpp @@ -18,7 +18,9 @@ #include "worldmap/level_tile.hpp" +#include #include +#include #include "editor/editor.hpp" #include "supertux/level_parser.hpp" @@ -79,6 +81,67 @@ LevelTile::~LevelTile() { } +namespace { + +// version tag on the serialised ghost path so records without it are discarded +const char* const GHOST_RUN_VERSION = "v2"; + +std::string serialize_ghost_run(const std::vector& path) +{ + std::ostringstream out; + out << GHOST_RUN_VERSION; + out << std::fixed << std::setprecision(4); + + for (const auto& point : path) + out << ' ' << point.time << ' ' << point.position.x << ' ' << point.position.y << ' ' << point.action; + + return out.str(); +} + +std::vector deserialize_ghost_run(const std::string& data) +{ + std::vector path; + std::istringstream in(data); + + std::string version; + if (!(in >> version) || version != GHOST_RUN_VERSION) + return path; + + float time, x, y; + std::string action; + while (in >> time >> x >> y >> action) + path.push_back({time, Vector(x, y), action}); + + return path; +} + +} // namespace + +void +LevelTile::set_best_ghost_run(const std::vector& best_ghost_run) +{ + m_best_ghost_run = best_ghost_run; +} + +void +LevelTile::serialize_best_ghost_run(ssq::Table& table) const +{ + if (m_best_ghost_run.empty()) + return; + + table.set("ghost-path", serialize_ghost_run(m_best_ghost_run)); +} + +void +LevelTile::unserialize_best_ghost_run(const ssq::Table& table) +{ + std::string path_data; + if (!table.get("ghost-path", path_data)) + return; + + m_best_ghost_run = deserialize_ghost_run(path_data); +} + void LevelTile::load_level_information() { diff --git a/src/worldmap/level_tile.hpp b/src/worldmap/level_tile.hpp index 0c6d974e04..045b3df33b 100644 --- a/src/worldmap/level_tile.hpp +++ b/src/worldmap/level_tile.hpp @@ -20,6 +20,10 @@ #include "worldmap/worldmap_object.hpp" +#include +#include + +#include "math/vector.hpp" #include "supertux/statistics.hpp" namespace worldmap { @@ -27,6 +31,14 @@ namespace worldmap { class LevelTile final : public WorldMapObject { public: + /** One sampled frame of a recorded run, stored to drive the speedrun ghost. */ + struct GhostRunPoint + { + float time; /**< seconds since start */ + Vector position; /**< player position */ + std::string action; /**< sprite action */ + }; + LevelTile(const ReaderMapping& mapping); ~LevelTile() override; @@ -47,6 +59,12 @@ class LevelTile final : public WorldMapObject inline Statistics& get_statistics() { return m_statistics; } inline const Statistics& get_statistics() const { return m_statistics; } + inline const std::vector& get_best_ghost_run() const { return m_best_ghost_run; } + inline bool has_best_ghost_run() const { return !m_best_ghost_run.empty(); } + void set_best_ghost_run(const std::vector& best_ghost_run); + void serialize_best_ghost_run(ssq::Table& table) const; + void unserialize_best_ghost_run(const ssq::Table& table); + void update_sprite_action(); inline const std::string& get_title() const { return m_title; } @@ -78,6 +96,7 @@ class LevelTile final : public WorldMapObject bool m_perfect; Statistics m_statistics; + std::vector m_best_ghost_run; Color m_title_color; diff --git a/src/worldmap/worldmap_sector.cpp b/src/worldmap/worldmap_sector.cpp index 0351be611c..68183c7c60 100644 --- a/src/worldmap/worldmap_sector.cpp +++ b/src/worldmap/worldmap_sector.cpp @@ -356,7 +356,7 @@ WorldMapSector::update(float dt_sec) level_->get_pos().y + 8 - m_camera->get_offset().y); std::string levelfile = m_parent.m_levels_path + level_->get_level_filename(); - auto game_session = std::make_unique(levelfile, m_parent.get_savegame(), &level_->get_statistics()); + auto game_session = std::make_unique(levelfile, m_parent.get_savegame(), &level_->get_statistics(), level_->get_best_ghost_run()); if (m_parent.m_really_enter_level) { game_session->skip_intro(); @@ -477,7 +477,7 @@ WorldMapSector::solved_level_count() const void -WorldMapSector::finished_level(Level* gamelevel) +WorldMapSector::finished_level(Level* gamelevel, const std::vector& level_path) { // TODO use Level* parameter here? auto level = at_object(); @@ -493,8 +493,25 @@ WorldMapSector::finished_level(Level* gamelevel) m_parent.get_savegame().get_player_status().add_tuxdolls( std::max(0, gamelevel->m_stats.get_tuxdolls() - level->get_statistics().get_tuxdolls())); + const float previous_best_time = level->get_statistics().get_time(); level->get_statistics().update(gamelevel->m_stats); + // Store the new run as the ghost on first completion or new personal best + if (previous_best_time == 0.0f || + (gamelevel->m_stats.get_time() > 0.0f && gamelevel->m_stats.get_time() < previous_best_time)) + { + std::vector best_ghost_run; + best_ghost_run.reserve(level_path.size()); + float timestamp = 0.0f; + const float sample_interval = (level_path.size() > 1) ? (gamelevel->m_stats.get_time() / static_cast(level_path.size() - 1)) : 0.0f; + for (const auto& frame : level_path) + { + best_ghost_run.push_back({timestamp, frame.position, frame.action}); + timestamp += sample_interval; + } + level->set_best_ghost_run(best_ghost_run); + } + if (level->get_statistics().completed(level->get_target_time())) { level->set_perfect(true); } diff --git a/src/worldmap/worldmap_sector.hpp b/src/worldmap/worldmap_sector.hpp index 6388afa7ab..b4f61d3084 100644 --- a/src/worldmap/worldmap_sector.hpp +++ b/src/worldmap/worldmap_sector.hpp @@ -20,6 +20,7 @@ #include "supertux/sector_base.hpp" +#include "worldmap/level_tile.hpp" #include "worldmap/tux.hpp" namespace worldmap { @@ -83,7 +84,7 @@ class WorldMapSector final : public Base::Sector /** gets called from the GameSession when a level has been successfully finished */ - void finished_level(Level* level); + void finished_level(Level* level, const std::vector& level_path); /** Get a spawnpoint by its name @param name The name of the spawnpoint @return spawnpoint corresponding to that name */ diff --git a/src/worldmap/worldmap_state.cpp b/src/worldmap/worldmap_state.cpp index 25289337c1..72e78dc640 100644 --- a/src/worldmap/worldmap_state.cpp +++ b/src/worldmap/worldmap_state.cpp @@ -195,6 +195,7 @@ WorldMapState::load_levels(const ssq::Table& table) level_tile.update_sprite_action(); level_tile.get_statistics().unserialize_from_squirrel(level); + level_tile.unserialize_best_ghost_run(level); } catch (const ssq::NotFoundException&) { @@ -309,6 +310,7 @@ WorldMapState::save_state(bool initial) const level.set("solved", level_tile.is_solved()); level.set("perfect", level_tile.is_perfect()); level_tile.get_statistics().serialize_to_squirrel(level); + level_tile.serialize_best_ghost_run(level); } /** Save tilemap visibility **/