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 **/