From fa8ba13d244e54fcaf85add0dccb29133e03fdaa Mon Sep 17 00:00:00 2001 From: Davi Rocha Date: Wed, 27 May 2026 18:44:30 -0300 Subject: [PATCH 1/2] Add speedrun ghost mode Add an optional speedrun mode where the player can race against a translucent replay of their best run in each level. The ghost follows the recorded movement and animations, but remains purely visual and does not interact with physics or world objects. The HUD timer also shows whether the player is ahead or behind the ghost during the run. Co-authored-by: Baltazar Reis --- src/object/ghost_replay.cpp | 160 +++++++++++++++++++++++++++++ src/object/ghost_replay.hpp | 56 ++++++++++ src/supertux/game_session.cpp | 103 ++++++++++++++++--- src/supertux/game_session.hpp | 23 ++++- src/supertux/gameconfig.cpp | 3 + src/supertux/gameconfig.hpp | 1 + src/supertux/menu/options_menu.cpp | 3 + src/supertux/menu/options_menu.hpp | 1 + src/worldmap/level_tile.cpp | 63 ++++++++++++ src/worldmap/level_tile.hpp | 19 ++++ src/worldmap/worldmap_sector.cpp | 21 +++- src/worldmap/worldmap_sector.hpp | 3 +- src/worldmap/worldmap_state.cpp | 2 + 13 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 src/object/ghost_replay.cpp create mode 100644 src/object/ghost_replay.hpp diff --git a/src/object/ghost_replay.cpp b/src/object/ghost_replay.cpp new file mode 100644 index 00000000000..236952b62c8 --- /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 00000000000..0d044a9b6c0 --- /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 a201cab43d1..e7522f6dd18 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, 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(std::move(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, std::vector best_ghost_run) : + GameSession{savegame, statistics, std::move(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, std::vector best_ghost_run) : + GameSession{&savegame, statistics, std::move(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, std::vector best_ghost_run) : + GameSession{savegame, statistics, std::move(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 2303e52f955..7cec33f7b96 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, + std::vector best_ghost_run = {} + ); + GameSession(Level* level, Savegame* savegame = nullptr, + Statistics* statistics = nullptr, + std::vector best_ghost_run = {} + ); + GameSession(const std::string& levelfile, Savegame& savegame, + Statistics* statistics = nullptr, + std::vector best_ghost_run = {} + ); + GameSession(std::istream& istream, Savegame* savegame = nullptr, + Statistics* statistics = nullptr, + 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 d3b5bace5bf..ca208d10a51 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 4f71a7a1fe1..646df100df2 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 bbc6b855597..14e85d6fc8d 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 dbd18384c87..a1128de043b 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 516368beab0..3ec4775b9eb 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(std::vector best_ghost_run) +{ + m_best_ghost_run = std::move(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 0c6d974e043..e40a947a292 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(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 0351be611c8..08f3076c218 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(std::move(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 6388afa7ab6..b4f61d3084f 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 25289337c1a..72e78dc640a 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 **/ From d62ccb799f5cc40e04787f8aa26274b7ce5fdc1e Mon Sep 17 00:00:00 2001 From: Davi Rocha Date: Wed, 3 Jun 2026 16:04:45 -0300 Subject: [PATCH 2/2] fix pass best_ghost_run by const reference --- src/supertux/game_session.cpp | 16 ++++++++-------- src/supertux/game_session.hpp | 8 ++++---- src/worldmap/level_tile.cpp | 4 ++-- src/worldmap/level_tile.hpp | 2 +- src/worldmap/worldmap_sector.cpp | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/supertux/game_session.cpp b/src/supertux/game_session.cpp index e7522f6dd18..f7277047de2 100644 --- a/src/supertux/game_session.cpp +++ b/src/supertux/game_session.cpp @@ -64,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, std::vector best_ghost_run) : +GameSession::GameSession(Savegame* savegame, Statistics* statistics, const std::vector& best_ghost_run) : reset_button(false), reset_checkpoint_button(false), m_prevent_death(false), @@ -97,7 +97,7 @@ GameSession::GameSession(Savegame* savegame, Statistics* statistics, std::vector m_end_seq_started(false), m_pause_target_timer(false), m_current_run_path(), - m_best_ghost_run(std::move(best_ghost_run)), + m_best_ghost_run(best_ghost_run), m_current_cutscene_text(), m_endsequence_timer() { @@ -110,20 +110,20 @@ GameSession::GameSession(Savegame* savegame, Statistics* statistics, std::vector } -GameSession::GameSession(Level* level, Savegame* savegame, Statistics* statistics, std::vector best_ghost_run) : - GameSession{savegame, statistics, std::move(best_ghost_run)} +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, std::vector best_ghost_run) : - GameSession{&savegame, statistics, std::move(best_ghost_run)} +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, std::vector best_ghost_run) : - GameSession{savegame, statistics, std::move(best_ghost_run)} +GameSession::GameSession(std::istream& istream_, Savegame* savegame, Statistics* statistics, const std::vector& best_ghost_run) : + GameSession{savegame, statistics, best_ghost_run} { m_levelstream = &istream_; } diff --git a/src/supertux/game_session.hpp b/src/supertux/game_session.hpp index 7cec33f7b96..7aac45d25d1 100644 --- a/src/supertux/game_session.hpp +++ b/src/supertux/game_session.hpp @@ -82,19 +82,19 @@ class GameSession final : public Screen, public: GameSession(Savegame* savegame = nullptr, Statistics* statistics = nullptr, - std::vector best_ghost_run = {} + const std::vector& best_ghost_run = {} ); GameSession(Level* level, Savegame* savegame = nullptr, Statistics* statistics = nullptr, - std::vector best_ghost_run = {} + const std::vector& best_ghost_run = {} ); GameSession(const std::string& levelfile, Savegame& savegame, Statistics* statistics = nullptr, - std::vector best_ghost_run = {} + const std::vector& best_ghost_run = {} ); GameSession(std::istream& istream, Savegame* savegame = nullptr, Statistics* statistics = nullptr, - std::vector best_ghost_run = {} + const std::vector& best_ghost_run = {} ); virtual void draw(Compositor& compositor) override; diff --git a/src/worldmap/level_tile.cpp b/src/worldmap/level_tile.cpp index 3ec4775b9eb..0bb8b66ef87 100644 --- a/src/worldmap/level_tile.cpp +++ b/src/worldmap/level_tile.cpp @@ -118,9 +118,9 @@ std::vector deserialize_ghost_run(const std::string& d } // namespace void -LevelTile::set_best_ghost_run(std::vector best_ghost_run) +LevelTile::set_best_ghost_run(const std::vector& best_ghost_run) { - m_best_ghost_run = std::move(best_ghost_run); + m_best_ghost_run = best_ghost_run; } void diff --git a/src/worldmap/level_tile.hpp b/src/worldmap/level_tile.hpp index e40a947a292..045b3df33b5 100644 --- a/src/worldmap/level_tile.hpp +++ b/src/worldmap/level_tile.hpp @@ -61,7 +61,7 @@ class LevelTile final : public WorldMapObject 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(std::vector best_ghost_run); + 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); diff --git a/src/worldmap/worldmap_sector.cpp b/src/worldmap/worldmap_sector.cpp index 08f3076c218..68183c7c60c 100644 --- a/src/worldmap/worldmap_sector.cpp +++ b/src/worldmap/worldmap_sector.cpp @@ -509,7 +509,7 @@ WorldMapSector::finished_level(Level* gamelevel, const std::vectorset_best_ghost_run(std::move(best_ghost_run)); + level->set_best_ghost_run(best_ghost_run); } if (level->get_statistics().completed(level->get_target_time())) {