From 7ed356d6203de2e1e324d14ef513f0bbf12581f6 Mon Sep 17 00:00:00 2001 From: James R Date: Wed, 26 Apr 2023 16:11:25 -0700 Subject: [PATCH 1/8] thirdparty: add nlohmann/json --- src/CMakeLists.txt | 2 ++ thirdparty/CMakeLists.txt | 2 ++ thirdparty/cpm-nlohmann-json.cmake | 6 ++++++ 3 files changed, 10 insertions(+) create mode 100644 thirdparty/cpm-nlohmann-json.cmake diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 43ad42f65..81c871de6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -254,6 +254,8 @@ if(SRB2_CONFIG_ENABLE_WEBM_MOVIES) target_compile_definitions(SRB2SDL2 PRIVATE -DSRB2_CONFIG_ENABLE_WEBM_MOVIES) endif() +target_link_libraries(SRB2SDL2 PRIVATE nlohmann_json::nlohmann_json) + set(SRB2_HAVE_THREADS ON) target_compile_definitions(SRB2SDL2 PRIVATE -DHAVE_THREADS) diff --git a/thirdparty/CMakeLists.txt b/thirdparty/CMakeLists.txt index f36851150..aecb684e1 100644 --- a/thirdparty/CMakeLists.txt +++ b/thirdparty/CMakeLists.txt @@ -34,6 +34,8 @@ if (SRB2_CONFIG_ENABLE_WEBM_MOVIES) include("cpm-libyuv.cmake") endif() +include("cpm-nlohmann-json.cmake") + add_subdirectory(tcbrindle_span) add_subdirectory(stb_vorbis) add_subdirectory(stb_rect_pack) diff --git a/thirdparty/cpm-nlohmann-json.cmake b/thirdparty/cpm-nlohmann-json.cmake new file mode 100644 index 000000000..c7d76ccd3 --- /dev/null +++ b/thirdparty/cpm-nlohmann-json.cmake @@ -0,0 +1,6 @@ +CPMAddPackage( + NAME nlohmann_json + VERSION 3.11.2 + URL "https://github.com/nlohmann/json/releases/download/v3.11.2/json.tar.xz" + EXCLUDE_FROM_ALL ON +) From 3ccfb71f04f21c33bf2fb50bae498dbcf94d06fa Mon Sep 17 00:00:00 2001 From: James R Date: Wed, 26 Apr 2023 14:32:55 -0700 Subject: [PATCH 2/8] Add Egg TV menu --- src/k_menu.h | 7 + src/menus/CMakeLists.txt | 2 + src/menus/class-egg-tv/CMakeLists.txt | 4 + src/menus/class-egg-tv/EggTV.cpp | 1156 ++++++++++++++++++++++ src/menus/class-egg-tv/EggTV.hpp | 335 +++++++ src/menus/class-egg-tv/EggTVData.cpp | 369 +++++++ src/menus/class-egg-tv/EggTVData.hpp | 268 +++++ src/menus/class-egg-tv/EggTVGraphics.hpp | 161 +++ src/menus/extras-1.c | 2 +- src/menus/extras-egg-tv.cpp | 131 +++ 10 files changed, 2434 insertions(+), 1 deletion(-) create mode 100644 src/menus/class-egg-tv/CMakeLists.txt create mode 100644 src/menus/class-egg-tv/EggTV.cpp create mode 100644 src/menus/class-egg-tv/EggTV.hpp create mode 100644 src/menus/class-egg-tv/EggTVData.cpp create mode 100644 src/menus/class-egg-tv/EggTVData.hpp create mode 100644 src/menus/class-egg-tv/EggTVGraphics.hpp create mode 100644 src/menus/extras-egg-tv.cpp diff --git a/src/k_menu.h b/src/k_menu.h index 5a343f0f7..675abe982 100644 --- a/src/k_menu.h +++ b/src/k_menu.h @@ -411,6 +411,9 @@ extern menu_t EXTRAS_ReplayHutDef; extern menuitem_t EXTRAS_ReplayStart[]; extern menu_t EXTRAS_ReplayStartDef; +extern menuitem_t EXTRAS_EggTV[]; +extern menu_t EXTRAS_EggTVDef; + // PAUSE extern menuitem_t PAUSE_Main[]; extern menu_t PAUSE_MainDef; @@ -1039,6 +1042,10 @@ boolean M_QuitReplayHut(void); void M_HutStartReplay(INT32 choice); void M_PrepReplayList(void); +// Extras: Egg TV +void M_EggTV(INT32 choice); +void M_EggTV_RefreshButtonLabels(void); + // Pause menu: diff --git a/src/menus/CMakeLists.txt b/src/menus/CMakeLists.txt index cb350244d..918aae4da 100644 --- a/src/menus/CMakeLists.txt +++ b/src/menus/CMakeLists.txt @@ -2,6 +2,7 @@ target_sources(SRB2SDL2 PRIVATE extras-1.c extras-addons.c extras-challenges.c + extras-egg-tv.cpp extras-replay-hut.c extras-statistics.c main-1.c @@ -40,4 +41,5 @@ target_sources(SRB2SDL2 PRIVATE play-online-server-browser.c ) +add_subdirectory(class-egg-tv) add_subdirectory(transient) diff --git a/src/menus/class-egg-tv/CMakeLists.txt b/src/menus/class-egg-tv/CMakeLists.txt new file mode 100644 index 000000000..83add54c0 --- /dev/null +++ b/src/menus/class-egg-tv/CMakeLists.txt @@ -0,0 +1,4 @@ +target_sources(SRB2SDL2 PRIVATE + EggTV.cpp + EggTVData.cpp +) diff --git a/src/menus/class-egg-tv/EggTV.cpp b/src/menus/class-egg-tv/EggTV.cpp new file mode 100644 index 000000000..557cfc793 --- /dev/null +++ b/src/menus/class-egg-tv/EggTV.cpp @@ -0,0 +1,1156 @@ +// DR. ROBOTNIK'S RING RACERS +//----------------------------------------------------------------------------- +// Copyright (C) 2023 by James Robert Roman +// +// This program is free software distributed under the +// terms of the GNU General Public License, version 2. +// See the 'LICENSE' file for more details. +//----------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include +#include + +#include "../../cxxutil.hpp" +#include "../../v_draw.hpp" +#include "EggTV.hpp" + +#include "../../doomstat.h" // gametic +//#include "../../doomtype.h" // intsign +#include "../../g_demo.h" +#include "../../g_game.h" // G_TicsToSeconds +#include "../../k_menu.h" +#include "../../m_easing.h" +#include "../../m_fixed.h" +#include "../../sounds.h" +#include "../../st_stuff.h" // faceprefix +#include "../../v_video.h" + +using namespace srb2::menus::egg_tv; + +using srb2::Draw; + +const EggTVGraphics::PatchManager EggTVGraphics::patches_; +const EggTVGraphics::ColorManager EggTVGraphics::colors_; + +namespace +{ + +struct FolderOffsets +{ + static constexpr int kTop = 51; + static constexpr int kBottom = 184; + static constexpr int kRowHeight = 15; + static constexpr int kRowsPerPage = (kBottom - kTop) / kRowHeight; +}; + +struct StandingsOffsets +{ + static constexpr int kLeft = 184; + static constexpr int kRight = 298; + static constexpr int kTop = 56; + static constexpr int kBottom = 188; + static constexpr int kRowHeight = 19; + static constexpr int kRowsPerPage = (kBottom - kTop) / kRowHeight; +}; + +void draw_face(const Draw& draw, const EggTVData::Replay::Standing& player, facepatches face) +{ + const skincolornum_t color = static_cast(player.color); + + if (player.skin) + { + draw.colormap(*player.skin, color).patch(faceprefix[*player.skin][face]); + } + else + { + switch (face) + { + case FACE_RANK: + draw.colorize(color).patch("M_NORANK"); + break; + + case FACE_WANTED: + draw.colorize(color).patch("M_NOWANT"); + break; + + default: + break; + } + } +} + +std::string player_time_string(const EggTVData::Replay::Standing& player) +{ + if (player.time) + { + return fmt::format( + R"({}'{}"{})", + G_TicsToMinutes(*player.time, true), + G_TicsToSeconds(*player.time), + G_TicsToCentiseconds(*player.time) + ); + } + else + { + return "NO CONTEST"; + } +} + +std::string player_points_string(const EggTVData::Replay::Standing& player) +{ + return player.score ? fmt::format("{} PTS", *player.score) : "NO CONTEST"; +} + +}; // namespace + +struct EggTV::GridOffsets +{ + static constexpr int + kTop = 47, + kBottom = 174, + kRight = 304, + kLeft = 15, + + kGridHeight = 148, + kRowHeight = 37, + kCellWidth = 58, + + kRowsPerPage = kGridHeight / kRowHeight; + + Draw draw; + Draw select; + + int row; + + explicit GridOffsets(const EggTV& tv) + { + // Center the second row by pushing it down + constexpr int kCenter = kTop + (((kBottom - kTop) - (kGridHeight - kRowHeight)) / 2); + + constexpr int kFlushBottom = kBottom - kGridHeight; + + const Cursor& p = tv.gridRow_; + + const int padTop = 1; + const int padBottom = p.end() - 3; + + const bool multiPage = padTop < padBottom; + + float y; + + if (p.pos() <= padTop) + { + y = kTop; + } + else if (multiPage && p.pos() <= padBottom) + { + y = kCenter; + } + else + { + y = kFlushBottom; + } + + const int high = p.change() > 0 ? p.pos() - p.change() : p.pos(); + + if (high >= padTop && high <= padBottom) + { + // Single page is a very special case. Scrolling + // by a single row is too tall. Make sure not to + // scroll past the end of the page! + const int gap = multiPage ? kRowHeight : (kTop - kFlushBottom); + + y += Easing_Linear(tv.rowSlide_.reverse(), 0, gap * p.change()); + } + + row = std::max(0, std::min(p.pos(), padBottom) - padTop); + + // Y may be offset downward when scrolling or due to + // centering. In this case, draw an extra row above so + // it doesn't leave any empty space. + if (y > kTop && multiPage) + { + SRB2_ASSERT(row > 0); + row--; + + y -= kRowHeight; + } + + draw = Draw(kLeft, y); + select = draw.xy(tv.gridCol_.pos() * kCellWidth, (tv.gridRow_.pos() - row) * kRowHeight); + } +}; + +template +EggTVGraphics::PatchManager::patch EggTVGraphics::PatchManager::Animation::animate(int interval) const +{ + return data[(gametic / interval) % data.size()]; +} + +EggTV::InputReaction EggTV::input(int pid) +{ + if (mode_.changing()) + { + return {}; + } + + InputReaction reaction; + + // Call this to make a sound effect! :D + auto react = [&reaction](bool is = true) { reaction.effect |= is; }; + + const menucmd_t& cmd = menucmd[pid]; + + switch (mode_) + { + case Mode::kFolders: + folderRow_ += cmd.dpad_ud; + + react(cmd.dpad_ud != 0); + break; + + case Mode::kGrid: + gridRow_ += cmd.dpad_ud; + gridCol_ += cmd.dpad_lr; + + if (cmd.dpad_ud != 0 || cmd.dpad_lr != 0) + { + dashTextScroll_.start(24); + react(); + } + break; + + case Mode::kReplay: + if (cmd.dpad_ud != 0) + { + buttonHover_.start(); + } + + // Use default menu handler for these options (see EXTRAS_EggTV) + reaction.bypass = true; + break; + + case Mode::kStandings: + standingsRow_ += cmd.dpad_ud; + + // If scroll wraps around, do not smooth scroll. That looks very weird. + if (standingsRow_.wrap()) + { + standingsRowSlide_.stop(); + } + + react(cmd.dpad_ud != 0); + break; + } + + if (M_MenuConfirmPressed(pid)) + { + if (select()) + { + if (mode_ == Mode::kGrid) + { + reaction.sound = sfx_s3k63; + } + } + else + { + if (mode_ == Mode::kGrid) + { + gridSelectShake_.start(); + } + + reaction.sound = sfx_s3kb2; + } + + react(); + } + else if (M_MenuBackPressed(pid)) + { + if (mode_ == Mode::kReplay) + { + // ...Except for backing out of the menu. + // Don't exit Egg TV entirely. + reaction.bypass = false; + } + + back(); + } + else if (M_MenuButtonPressed(pid, MBT_R)) + { + if (mode_ == Mode::kFolders) + { + switch (folderSort_) + { + case FolderSort::kDate: + folderSort_ = FolderSort::kName; + break; + + case FolderSort::kName: + folderSort_ = FolderSort::kSize; + break; + + case FolderSort::kSize: + folderSort_ = FolderSort::kDate; + break; + } + + sort_folders(); + } + } + + return reaction; +} + +bool EggTV::select() +{ + switch (mode_) + { + case Mode::kFolders: + if (folders_.empty()) + { + break; + } + + { + Folder& folder = folders_[folderRow_.pos()]; + + cache_ = folder.load(); + + // Restore previous selection. + gridCol_ = folder.x; + gridRow_ = folder.y; + } + + folderSlide_.start(); + dashRise_.start_with(folderSlide_ + 6); + gridFade_.start_after(folderSlide_); + gridPopulate_.start_with(gridFade_); + dashTextRise_.end_with(gridPopulate_ + 2); + dashTextScroll_.start_after(dashTextRise_ + 4); + + mode_.change(Mode::kGrid, folderSlide_.stopping_point()); + + return true; + + case Mode::kGrid: + if (grid_index() >= cache_->size()) + { + break; + } + + if (cache_->replay(grid_index())->invalid()) + { + break; + } + + M_EggTV_RefreshButtonLabels(); + + dashTextRise_.start(); + enhanceMove_.start_after(dashTextRise_); + enhanceMove_.extend(gridCol_.pos() * 0.75); // extend time with distance + enhanceZoom_.start_after(enhanceMove_); + replaySlide_.start_after(enhanceZoom_); + buttonSlide_.start_with(replaySlide_); + replayTitleScroll_.start_after(replaySlide_); + gridPopulate_.end_with(enhanceZoom_); + gridFade_.end_with(gridPopulate_); + + mode_.change(Mode::kReplay, enhanceZoom_.stopping_point()); + itemOn = 0; // WATCH REPLAY + + return true; + + case Mode::kReplay: + break; + + case Mode::kStandings: + back(); + return true; + } + + return false; +} + +void EggTV::back() +{ + switch (mode_) + { + case Mode::kFolders: + M_GoBack(0); + break; + + case Mode::kGrid: + { + Folder& folder = cache_->folder(); + + // Save current selection. + folder.x = gridCol_.pos(); + folder.y = gridRow_.pos(); + } + + dashTextRise_.start(); + gridPopulate_.start(); + gridPopulate_.extend(-0.5); + gridFade_.end_with(gridPopulate_); + dashRise_.start_after(gridFade_); + folderSlide_.start_with(dashRise_); + + mode_.change(Mode::kFolders, folderSlide_.stopping_point()); + break; + + case Mode::kReplay: + replaySlide_.start(); + buttonSlide_.start(); + replaySlide_.extend(-0.5); + buttonSlide_.extend(-0.5); + gridPopulate_.start_after(replaySlide_); + gridPopulate_.extend(-0.75); + gridFade_.start_with(gridPopulate_); + enhanceZoom_.start_with(gridPopulate_); + enhanceMove_.end_with(gridPopulate_); + dashTextRise_.end_with(gridPopulate_); + dashTextScroll_.start_after(dashTextRise_ + 4); + + mode_.change(Mode::kGrid, gridPopulate_.stopping_point()); + break; + + case Mode::kStandings: + buttonSlide_.start(); + buttonSlide_.extend(-0.125); + mode_.change(Mode::kReplay, buttonSlide_.stopping_point()); + break; + } +} + +void EggTV::watch() const +{ + SRB2_ASSERT(cache_.get()); + + std::shared_ptr replay = cache_->replay(grid_index()); + + if (replay) + { + restoreMenu = currentMenu; + + M_ClearMenus(false); + + G_DoPlayDemo(replay->path().string().c_str()); + } +} + +void EggTV::erase() +{ + SRB2_ASSERT(cache_.get()); + + { + std::shared_ptr replay = cache_->replay(grid_index()); + + if (replay) + { + // Will not be deleted until shared_ptr is released + replay->mark_for_deletion(); + } + } + + if (cache_->folder().empty()) + { + // Remove empty folder from list + folders_.erase(std::find(folders_.begin(), folders_.end(), cache_->folder())); + folderRow_ += 0; // clamp cursor + + cache_.reset(); + + mode_.change(Mode::kFolders, 0); + } + else + { + mode_.change(Mode::kGrid, 0); + } +} + +void EggTV::toggle_favorite() +{ + SRB2_ASSERT(cache_.get()); + + std::shared_ptr replay = cache_->replay(grid_index()); + + if (replay) + { + replay->toggle_favorite(); + + M_EggTV_RefreshButtonLabels(); + + favSlap_.start(); + } +} + +bool EggTV::favorited() const +{ + if (!cache_) + { + return false; + } + + std::shared_ptr replay = cache_->replay(grid_index()); + + return replay && replay->favorited(); +} + +void EggTV::standings() +{ + standingsRow_ = 0; + + buttonSlide_.start(); + + mode_.change(Mode::kStandings, buttonSlide_.stopping_point()); +} + +std::size_t EggTV::grid_rows() const +{ + return std::max(static_cast(GridOffsets::kRowsPerPage), cache_->size() / kGridColsPerRow); +} + +std::size_t EggTV::standings_rows() const +{ + constexpr int kPerPage = StandingsOffsets::kRowsPerPage; + + return (cache_->replay(grid_index())->standings().size() + (kPerPage - 1)) / kPerPage; +} + +EggTVGraphics::PatchManager::patch EggTV::gametype_graphic(const Replay& replay) const +{ + const auto& it = patches_.gametype.find(replay.gametype().name()); + + return it != patches_.gametype.end() ? it->second : nullptr; +} + +void EggTV::draw() const +{ + draw_background(); + + switch (mode_) + { + case Mode::kFolders: + draw_folders(); + + if (dashRise_.running()) + { + draw_dash(); + } + break; + + case Mode::kGrid: + if (gridPopulate_.running()) + { + draw_grid(); + } + else if (mode_.changing_to(Mode::kFolders)) + { + draw_folders(); + } + else + { + draw_grid(); + } + + draw_dash(); + break; + + case Mode::kReplay: + case Mode::kStandings: + if (mode_.changing_to(Mode::kGrid) && !replaySlide_.running()) + { + draw_grid(); + draw_dash(); + } + else + { + std::shared_ptr replay = cache_->replay(grid_index()); + SRB2_ASSERT(replay); + draw_replay(*replay); + } + break; + } + + draw_overlay(); + + if (mode_ == Mode::kFolders) + { + draw_folder_header(); + } + + if (mode_ != Mode::kReplay && !gridPopulate_.running() && !folderSlide_.running() && !buttonSlide_.running()) + { + draw_scroll_bar(); + } +} + +void EggTV::draw_background() const +{ + Draw(0, 0).patch(patches_.bg.animate(2)); +} + +void EggTV::draw_overlay() const +{ + Draw(0, 0).flags(V_MODULATE).patch(patches_.mod); + Draw(0, 0).patch(patches_.overlay); + + Draw(160, 3) + .font(Draw::Font::kGamemode) + .align(Draw::Align::kCenter) + .text("Egg TV"); +} + +void EggTV::draw_folder_header() const +{ + const Draw header = Draw(0, 39).flags(V_YELLOWMAP).font(Draw::Font::kThin); + + header.x(32).text("GAME VERSION:"); + header.x(198).text("REPLAYS:"); + header.x(245).text("LAST PLAYED:"); +} + +void EggTV::draw_folders() const +{ + constexpr int kPadding = 4; + + const float x = Easing_InExpo(folderSlide_.reverse_if(mode_.changing_to(Mode::kFolders)), 0, -160); + + Draw row(x, FolderOffsets::kTop); + + int start = std::max(0, folderRow_.pos() - kPadding); + const int stop = folders_.size(); + + for (int i = start; i < stop; ++i) + { + const Folder& folder = folders_[i]; + + const int mode = (i == folderRow_.pos() ? 1 : 0); + + row.x(26).flags(V_TRANSLUCENT).colormap(colors_.bar[mode]).patch(patches_.bar); + row.x(32).colormap(colors_.folder[mode]).patch(patches_.folder[mode]); + + const Draw text = row.y(2).flags(mode ? V_YELLOWMAP : 0); + + text.x(52).clipx(0, 186).text(folder.name()); + text.x(228).align(Draw::Align::kRight).text("{}", folder.size()); + text.x(244).text("{:%d %b %Y}", folder.time()); + + row = row.y(FolderOffsets::kRowHeight); + + // went below the viewport + if (row.y() >= FolderOffsets::kBottom) + { + break; + } + } +} + +void EggTV::draw_scroll_bar() const +{ + Draw bar = Draw(311, 0).colormap(colors_.scroll); + + bar.y(41).patch(patches_.scroll.arrow.up); + bar.y(191).patch(patches_.scroll.arrow.down); + + constexpr int kTop = 48; + constexpr int kBottom = 190; + constexpr int kHeight = (kBottom - kTop); + constexpr int kBeadHeight = 3; + constexpr int kMaxMid = (kHeight - (2 * kBeadHeight)); + + float thisPage = 0.f; + float pages = 1.f; + + auto curse = [&](const Cursor& p, float perPage) + { + thisPage = p.pos() / std::max(1.f, p.end() - 1.f); + pages = std::max(1.f, p.end() / perPage); + }; + + switch (mode_) + { + case Mode::kFolders: + curse(folderRow_, FolderOffsets::kRowsPerPage); + break; + + case Mode::kGrid: + curse(gridRow_, GridOffsets::kRowsPerPage); + break; + + case Mode::kStandings: + curse(standingsRow_, 1); + break; + + default: + break; + } + + SRB2_ASSERT(pages >= 1.f); + + const float mid = std::max(static_cast(kMaxMid / pages), kBeadHeight); + const float y = thisPage * (kMaxMid - mid); + + bar = bar.y(kTop + y); + + bar.patch(patches_.scroll.bead.top); + bar = bar.y(kBeadHeight); + bar.height(mid).stretch(Draw::Stretch::kHeight).patch(patches_.scroll.bead.mid); + bar.y(mid).patch(patches_.scroll.bead.bottom); +} + +void EggTV::draw_dash() const +{ + const float y = Easing_Linear(dashRise_.reverse().invert_if(mode_.changing_to(Mode::kFolders)), 174, 188); + Draw(15, y).patch(patches_.dash); + + if (!mode_.changing_to(Mode::kReplay) || dashTextRise_.running()) + { + std::shared_ptr replay = cache_->replay(grid_index()); + + if (replay) + { + draw_dash_text(*replay); + } + } +} + +void EggTV::draw_dash_text(const Replay& replay) const +{ + const Draw::TextElement text = Draw::TextElement(replay.title()).font(Draw::Font::kThin); + + const int halfWidth = text.width() / 2; + + const fixed_t t = (dashTextScroll_.variable() + (FRACUNIT/2)) % FRACUNIT; + const float x = Easing_Linear(t, GridOffsets::kRight + halfWidth, GridOffsets::kLeft - halfWidth); + const float y = Easing_Linear(dashTextRise_.reverse().invert_if(mode_.next() != Mode::kGrid), 177, 188); + + Draw(x, y).align(Draw::Align::kCenter).text(text); +} + +template +void EggTV::draw_grid() const +{ + const GridOffsets grid(*this); + + const std::size_t firstIdx = grid.row * kGridColsPerRow; + + Draw row = grid.draw; + std::size_t idx = firstIdx; + + auto draw_cell_populating = [&](Draw cell, const Replay* replay) + { + if (!replay || (mode_.changing_to(Mode::kReplay) && idx == grid_index())) + { + cell.flags(V_MODULATE).patch(patches_.empty); + return; + } + + if (replay->invalid()) + { + cell.flags(V_MODULATE).colorize(SKINCOLOR_RED).patch(patches_.empty); + return; + } + + cell.patch(patches_.tv.animate(2)); + }; + + auto draw_cell_background = [&](Draw cell, const Replay* replay) + { + const int mode = idx == grid_index() ? 1 : 0; + + if (!replay) + { + cell.flags(V_MODULATE).patch(patches_.nodata[mode].animate(2)); + return; + } + + if (replay->invalid()) + { + cell.flags(V_MODULATE).patch(patches_.corrupt[mode].animate(2)); + return; + } + + cell.width(GridOffsets::kCellWidth).thumbnail(replay->map()); + }; + + auto draw_cell_foreground = [&](Draw cell, const Replay* replay) + { + if (!replay || replay->invalid()) + { + return; + } + + if (replay->winner()) + { + draw_face(cell.xy(1, 20), *replay->winner(), FACE_RANK); + } + + PatchManager::patch gt = gametype_graphic(*replay); + + if (gt) + { + cell.xy(40, 1).patch(gt); + } + + cell + .xy(GridOffsets::kCellWidth - 3, 25) + .align(Draw::Align::kRight) + .font(Draw::Font::kThin) + .text("{:%d %b %y}", replay->date()); + }; + + auto draw_cell_fav = [&](Draw cell, const Replay* replay) + { + if (replay && replay->favorited()) + { + cell.xy(1, 1).patch(patches_.fav); + } + }; + + auto draw_row = [&](const int rightEdge, auto draw_cell) + { + Draw cell = row; + + while (cell.x() < rightEdge) + { + std::shared_ptr replay = cache_->replay(idx); + + draw_cell(cell, replay.get()); + + cell = cell.x(GridOffsets::kCellWidth); + idx++; + } + + row = row.y(GridOffsets::kRowHeight); + }; + + if constexpr (K == GridMode::kPopulating) + { + int cols = Easing_Linear(gridPopulate_.reverse().invert_if(mode_.next() != Mode::kGrid), 8, 0); + + while (cols > 0 && row.y() < GridOffsets::kBottom) + { + const int next = idx + kGridColsPerRow; + + draw_row(std::min(GridOffsets::kRight, static_cast(row.x()) + (cols * 58)), draw_cell_populating); + cols--; + + // During the animation, some cells are skipped + // entirely, so always update the index to the + // next row. + idx = next; + } + + draw_grid_mesh(grid); + + if (mode_ == Mode::kReplay || mode_.changing_to(Mode::kReplay)) + { + draw_grid_enhance(grid); + } + } + else if constexpr (K == GridMode::kFinal) + { + auto loop = [&](auto draw_cell) + { + row = grid.draw; + idx = firstIdx; + + while (row.y() < GridOffsets::kBottom) + { + draw_row(GridOffsets::kRight, draw_cell); + } + }; + + loop(draw_cell_background); + + draw_grid_mesh(grid); + + // Foreground elements must be drawn over mesh. + loop(draw_cell_foreground); + + draw_grid_select(grid); + + // Fav star is drawn over select =) + loop(draw_cell_fav); + } +} + +void EggTV::draw_grid_mesh(const GridOffsets& grid) const +{ + const fixed_t t = gridFade_.reverse_if(mode_.next() == Mode::kGrid); + const INT32 transFlag = t < FRACUNIT/2 ? 0 : V_TRANSLUCENT; + + // FIXME, hwr2d transparency does not work for other blend modes yet +#if 0 + Draw mesh = grid.draw.flags(V_MODULATE | transFlag); +#else + Draw mesh = grid.draw.flags(transFlag); +#endif + + while (mesh.y() < GridOffsets::kBottom) + { + mesh.patch(patches_.grid); + mesh = mesh.y(GridOffsets::kGridHeight); + } +} + +void EggTV::draw_grid_select(const GridOffsets& grid) const +{ + const float x = Easing_Linear(gridSelectX_.reverse(), 0, GridOffsets::kCellWidth * gridCol_.change()); + const float y = Easing_Linear(gridSelectY_.reverse(), 0, GridOffsets::kRowHeight * gridRow_.change()); + + // TODO, make this part of the Animation class...? + const float shake = [this] + { + fixed_t t = gridSelectShake_.variable(); + + if (t >= 3*FRACUNIT/4) + { + t = -(FRACUNIT) + t; + } + else if (t >= FRACUNIT/4) + { + t = (FRACUNIT/2) - t; + } + + return Easing_Linear(t, 0, 6 * 4); + }(); + + const skincolornum_t color = colors_.select[gridSelectShake_.running() ? 1 : 0]; + + grid.select.xy(-(x) + shake, -(y)).colorize(color).patch(patches_.select); +} + +void EggTV::draw_grid_enhance(const GridOffsets& grid) const +{ + const bool invert = mode_.changing_to(Mode::kGrid); + + const fixed_t move = enhanceMove_.reverse().invert_if(invert); + const fixed_t zoom = enhanceZoom_.reverse().invert_if(invert); + + const float x = Easing_Linear(move, 22, grid.select.x()); + const float y = Easing_Linear(move, 54, grid.select.y()); + + const float width = Easing_Linear(zoom, 160, GridOffsets::kCellWidth); + const float height = Easing_Linear(zoom, 100, GridOffsets::kRowHeight); + + draw_replay_photo(*cache_->replay(grid_index()), Draw(x, y).size(width, height)); +} + +void EggTV::draw_replay(const Replay& replay) const +{ + const Replay::Gametype::Race* race = replay.gametype().race(); + const Replay::Standing* winner = replay.winner(); + + Draw pic = Draw(22, 54).size(160, 100); + + draw_replay_photo(replay, pic); + + if (mode_ == Mode::kReplay || buttonSlide_.running()) + { + draw_replay_buttons(); + } + + if (mode_ == Mode::kStandings || mode_.changing_to(Mode::kStandings)) + { + draw_standings(replay); + } + + Draw box = pic.x(Easing_OutQuad(replaySlide_.reverse().invert_if(mode_.next() == Mode::kGrid), 0, -182)); + + { + Draw row = box.y(2).font(Draw::Font::kThin); + + row.x(1).patch(gametype_graphic(replay)); + + if (race) + { + row.x(19).align(Draw::Align::kLeft).text("({} laps)", race->laps); + } + + row.x(160 - 3).align(Draw::Align::kRight).text("{:%d %B %Y}", replay.date()); + } + + { + Draw row = box.xy(39, 104).align(Draw::Align::kLeft); + + auto pair = [&row](int x, auto label, auto text) + { + row = row.y(10); + row.y(-1).flags(V_AQUAMAP).font(Draw::Font::kThin).text(label); + row.x(x).font(Draw::Font::kConsole).text(text); + }; + + Draw gametype = row.font(Draw::Font::kConsole); + + if (race) + { + gametype.text("Race ({})", race->speed); + } + else + { + gametype.text(replay.gametype().name()); + } + + if (winner) + { + pair(38, "WINNER", winner->name); + + if (replay.gametype().ranks_time()) + { + pair(32, "TIME", player_time_string(*winner)); + } + else if (replay.gametype().ranks_points()) + { + pair(32, "SCORE", player_points_string(*winner)); + } + } + } + + if (winner) + { + draw_face(box.xy(2, 101), *winner, FACE_WANTED); + } + + { + constexpr Draw::Font kFont = Draw::Font::kThin; + + constexpr int kFavWidth = 11; + constexpr int kMargin = 1; + + constexpr int kLeft = 11 + kFavWidth + kMargin; + constexpr int kRight = 160 - kMargin; + constexpr int kInnerWidth = kRight - kLeft; + + const Draw::TextElement upper = Draw::TextElement(replay.title().first()).font(kFont); + const Draw::TextElement lower = Draw::TextElement(replay.title().second()).font(kFont); + + const float upperWidth = upper.width(); + const float lowerWidth = lower.width(); + const float widest = std::max(upperWidth, lowerWidth); + const float inside = std::min(widest, kInnerWidth + 0.f); + + constexpr fixed_t kPad = FRACUNIT/4; + + const Animation::Value val = replayTitleScroll_.variable(); + const fixed_t n = (val.invert_if(val > FRACUNIT/2) * 2) % (FRACUNIT + 1); + const fixed_t t = FixedDiv(std::clamp(n, kPad, FRACUNIT - kPad) - kPad, kPad * 2); + + const float scroll = Easing_Linear(t, widest - kInnerWidth, 0) - widest; + const float upperScroll = upperWidth > kInnerWidth ? scroll : -(inside); + const float lowerScroll = lowerWidth > kInnerWidth ? scroll : -(inside); + + Draw inner = box.xy(kLeft, 82).width(kInnerWidth).clipx(); + Draw title = inner.x(kInnerWidth); + + title.x(upperScroll).text(upper); + title.xy(lowerScroll, 8).flags(V_AQUAMAP).text(lower); + + if (replay.favorited()) + { + const Animation::Value t = favSlap_.reverse(); + + box + .xy(kRight - (kFavWidth + kMargin + inside), 82) + .scale(FixedToFloat(Easing_InBack(t, FRACUNIT, 4*FRACUNIT))) + .patch(patches_.fav); + } + } +} + +void EggTV::draw_replay_photo(const Replay& replay, Draw pic) const +{ + pic.xy(2, 2).fill(0x1F); + pic.thumbnail(replay.map()); +} + +void EggTV::draw_replay_buttons() const +{ + const float x = Easing_OutQuad(buttonSlide_.reverse().invert_if(mode_.next() != Mode::kReplay), 192, 305); + + Draw row(x, 54); + + for (INT16 i = 0; i < currentMenu->numitems; ++i) + { + const menuitem_t& item = currentMenu->menuitems[i]; + + if (item.status & IT_STRING) + { + SRB2_ASSERT(item.text != nullptr); + + const int mode = (i == itemOn ? 1 : 0); + + Draw button = row.x(mode ? Easing_InSine(buttonHover_.reverse(), -6, 0) : 0); + + if (!(item.status & IT_TRANSTEXT)) + { + button.colormap(colors_.button[mode]).patch(patches_.button); + } + + button.xy(13, 1).font(Draw::Font::kFreeplay).flags(mode ? V_YELLOWMAP : 0).text(item.text); + } + + row = row.y(18); + } +} + +void EggTV::draw_standings(const Replay& replay) const +{ + constexpr int kWidth = StandingsOffsets::kRight - StandingsOffsets::kLeft; + + const float x = Easing_InQuad(buttonSlide_.reverse().invert_if(mode_.changing_to(Mode::kReplay)), 0, kWidth); + const float y = Easing_Linear( + standingsRowSlide_.reverse_if(standingsRow_.change() > 0), + 0, + StandingsOffsets::kRowHeight + ); + + Draw row = Draw(StandingsOffsets::kLeft - x, StandingsOffsets::kTop + y) + .clipx(StandingsOffsets::kLeft, StandingsOffsets::kRight).font(Draw::Font::kConsole).align(Draw::Align::kRight); + + std::size_t start = standingsRow_.pos(); + + const bool overdraw = start > 0 && !(standingsRowSlide_.running() && standingsRow_.change() < 0); + + if (overdraw) + { + start--; + } + + if (overdraw || standingsRowSlide_.running()) + { + row = row.y(-(StandingsOffsets::kRowHeight)); + } + + for (std::size_t i = start; i < replay.standings().size(); ++i) + { + const Replay::Standing& player = replay.standings()[i]; + + row.x(18).flags(V_AQUAMAP).text("{}", 1 + i); + + draw_face(row.x(kWidth - 16), player, FACE_RANK); + + { + Draw text = row.font(Draw::Font::kThin).align(Draw::Align::kLeft); + + text.x(22).text(player.name); + + if (replay.gametype().ranks_time()) + { + text.xy(26, 8).text(player_time_string(player)); + } + else if (replay.gametype().ranks_points()) + { + text.xy(26, 8).text(player_time_string(player)); + } + } + + row = row.y(StandingsOffsets::kRowHeight); + } +} diff --git a/src/menus/class-egg-tv/EggTV.hpp b/src/menus/class-egg-tv/EggTV.hpp new file mode 100644 index 000000000..dbced6392 --- /dev/null +++ b/src/menus/class-egg-tv/EggTV.hpp @@ -0,0 +1,335 @@ +// DR. ROBOTNIK'S RING RACERS +//----------------------------------------------------------------------------- +// Copyright (C) 2023 by James Robert Roman +// +// This program is free software distributed under the +// terms of the GNU General Public License, version 2. +// See the 'LICENSE' file for more details. +//----------------------------------------------------------------------------- + +#ifndef __EGGTV_HPP__ +#define __EGGTV_HPP__ + +#include +#include +#include +#include + +#include "EggTVData.hpp" +#include "EggTVGraphics.hpp" + +#include "../../doomdef.h" // TICRATE +#include "../../i_time.h" +#include "../../k_menu.h" +#include "../../m_fixed.h" +#include "../../sounds.h" +#include "../../v_draw.hpp" + +namespace srb2::menus::egg_tv +{ + +class EggTV : private EggTVData, private EggTVGraphics +{ +public: + struct InputReaction + { + bool bypass = false; // use default menu controls + bool effect = false; // input was not ignored + sfxenum_t sound = sfx_s3k5b; + }; + + explicit EggTV() : EggTVData() {} + + InputReaction input(int pid); + + bool select(); + void back(); + + void watch() const; + void erase(); + void toggle_favorite(); + void standings(); + + bool favorited() const; + + void draw() const; + +private: + // TODO: separate some of these classes into separate files for general use + class Animation + { + public: + class Value + { + public: + Value(fixed_t n) : n_(n) {} + + Value invert() const { return FRACUNIT - n_; } + Value invert_if(bool yes) const { return yes ? invert() : *this; } + + operator fixed_t() const { return n_; } + + private: + fixed_t n_; + }; + + enum Timing + { + kOnce, + kLooping, + }; + + explicit Animation(unsigned int duration, Timing kind = kOnce) : kind_(kind), defaultDuration_(duration) {} + + void start(int offset = 0) { start_at(now() + offset); } + void stop() { start_at(now() - duration()); } + void start_with(const Animation& b) { start_at(b.starting_point()); } + void start_after(const Animation& b) { start_at(b.stopping_point()); } + void end_with(const Animation& b) { start_at(b.stopping_point() - duration()); } + void end_before(const Animation& b) { start_at(b.starting_point() - duration()); } + + void delay() { start(1 - elapsed()); } + void extend(float f) { duration_ += defaultDuration_ * f; } + + tic_t starting_point() const { return epoch_; } + tic_t stopping_point() const { return epoch_ + duration(); } + + bool running() const + { + switch (kind_) + { + case kOnce: + return elapsed() < duration(); + + case kLooping: + return !delayed(); + } + + return false; + } + + bool delayed() const { return starting_point() > now(); } + + unsigned int elapsed() const { return delayed() ? 0 : now() - starting_point(); } + unsigned int duration() const { return duration_; } + + Value variable() const { return variable_or(0); } + Value reverse() const { return FRACUNIT - variable_or(FRACUNIT); } + Value reverse_if(bool yes) const { return yes ? reverse() : variable(); } + + Animation operator +(int offset) const + { + Animation anim = *this; + anim.epoch_ += offset; + return anim; + } + + Animation operator -(int offset) const { return *this + -(offset); } + + private: + Timing kind_; + unsigned int defaultDuration_; + unsigned int duration_ = defaultDuration_; + tic_t epoch_ = 0; + + void start_at(tic_t when) + { + epoch_ = when; + duration_ = defaultDuration_; + } + + fixed_t variable_or(fixed_t alt) const + { + switch (kind_) + { + case kOnce: + return running() ? count() : alt; + + case kLooping: + return count() % FRACUNIT; + } + + return alt; + } + + fixed_t count() const { return (elapsed() * FRACUNIT) / std::max(1u, duration()); } + + static tic_t now() { return gametic; } + }; + + class Mode + { + public: + enum Value + { + kFolders, + kGrid, + kReplay, + kStandings, + }; + + explicit Mode(Value mode) : next_(mode) {} + + void change(Value mode, tic_t when) + { + prev_ = next_; + next_ = mode; + stop_ = when; + } + + Value get() const { return (stop_ <= gametic ? next_ : prev_); } + Value next() const { return next_; } + + bool changing() const { return gametic < stop_; } + bool changing_to(Value type) const { return changing() && next_ == type; } + + bool operator ==(Value b) const { return get() == b; } + operator Value() const { return get(); } + + private: + Value next_; + Value prev_ = next_; + tic_t stop_ = 0; + }; + + class Cursor + { + public: + using limiter_t = std::function; + using anims_t = std::vector; + + explicit Cursor(anims_t anims, limiter_t limiter) : limiter_(limiter), anims_(anims) {} + + int pos() const { return pos_; } + int end() const { return std::max(1, limiter_()); } + float fraction() const { return pos() / static_cast(end()); } + int change() const { return change_; } + + bool wrap() const + { + const int prev = pos() - change(); + + return prev < 0 || prev >= end(); + } + + Cursor& operator +=(int n) + { + if (n != 0) + { + pos_ += n; + change_ = n; + } + + if (pos_ < 0) + { + pos_ = end() - 1; + } + else + { + pos_ %= end(); + } + + if (n != 0) + { + // TODO? + for (Animation* anim : anims_) + { + if (anim->running()) + { + anim->delay(); + } + else + { + anim->start(); + } + } + } + + return *this; + } + + Cursor& operator =(int n) + { + pos_ = n; + pos_ += 0; + + return *this; + } + + private: + limiter_t limiter_; + + int pos_ = 0; + int change_ = 0; + + anims_t anims_; + }; + + Mode mode_{Mode::kFolders}; + + Animation rowSlide_{4}; + Animation folderSlide_{8}; + Animation dashRise_{6}; + Animation dashTextRise_{8}; + Animation dashTextScroll_{12*TICRATE, Animation::kLooping}; + Animation gridFade_{4}; + Animation gridPopulate_{16}; + Animation gridSelectX_{3}; + Animation gridSelectY_{3}; + Animation gridSelectShake_{6}; + Animation enhanceMove_{2}; + Animation enhanceZoom_{4}; + Animation replaySlide_{6}; + Animation buttonSlide_{6}; + Animation buttonHover_{4}; + Animation replayTitleScroll_{24*TICRATE, Animation::kLooping}; + Animation favSlap_{8}; + Animation standingsRowSlide_{6}; + + static constexpr int kGridColsPerRow = 5; + + Cursor folderRow_{{&rowSlide_}, [this]{ return folders_.size(); }}; + Cursor gridCol_{{&gridSelectX_}, []{ return kGridColsPerRow; }}; + Cursor gridRow_{{&gridSelectY_, &rowSlide_}, [this] { return grid_rows(); }}; + Cursor standingsRow_{{&standingsRowSlide_}, [this]{ return standings_rows(); }}; + + std::size_t grid_index() const { return gridCol_.pos() + (gridRow_.pos() * kGridColsPerRow); } + std::size_t grid_rows() const; + + std::size_t standings_rows() const; + + PatchManager::patch gametype_graphic(const Replay& replay) const; + + void draw_background() const; + void draw_overlay() const; + void draw_folder_header() const; + void draw_folders() const; + void draw_scroll_bar() const; + void draw_dash() const; + void draw_dash_text(const Replay& replay) const; + + enum class GridMode + { + kPopulating, + kFinal, + }; + + struct GridOffsets; + + template + void draw_grid() const; + + void draw_grid_mesh(const GridOffsets& grid) const; + void draw_grid_select(const GridOffsets& grid) const; + void draw_grid_enhance(const GridOffsets& grid) const; + + void draw_replay(const Replay& replay) const; + void draw_replay_photo(const Replay& replay, Draw pic) const; + void draw_replay_buttons() const; + + void draw_standings(const Replay& replay) const; +}; + +}; // namespace srb2::menus::egg_tv + +#endif // __EGGTV_HPP__ diff --git a/src/menus/class-egg-tv/EggTVData.cpp b/src/menus/class-egg-tv/EggTVData.cpp new file mode 100644 index 000000000..1f1599cbc --- /dev/null +++ b/src/menus/class-egg-tv/EggTVData.cpp @@ -0,0 +1,369 @@ +// DR. ROBOTNIK'S RING RACERS +//----------------------------------------------------------------------------- +// Copyright (C) 2023 by James Robert Roman +// +// This program is free software distributed under the +// terms of the GNU General Public License, version 2. +// See the 'LICENSE' file for more details. +//----------------------------------------------------------------------------- + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include // std::filesystem::path formatter +#include + +#include "../../cxxutil.hpp" +#include "EggTVData.hpp" + +#include "../../doomdef.h" // CONS_Alert +#include "../../doomstat.h" // gametypes +#include "../../g_demo.h" +#include "../../k_menu.h" // DF_ENCORE +#include "../../r_skins.h" + +using namespace srb2::menus::egg_tv; + +namespace fs = std::filesystem; + +using nlohmann::json; + +template <> +struct fmt::formatter : formatter +{ + template + auto format(const fs::filesystem_error& ex, FormatContext& ctx) const + { + return formatter::format( + fmt::format("{}, path1={}, path2={}", ex.what(), ex.path1(), ex.path2()), + ctx + ); + } +}; + +namespace +{ + +template +void print_error(fmt::format_string format, Args&&... args) +{ + CONS_Alert(CONS_ERROR, "Egg TV: %s\n", fmt::format(format, args...).c_str()); +} + +template +To time_point_conv(From time) +{ + // https://stackoverflow.com/a/58237530/10850779 + return std::chrono::time_point_cast(To::clock::now() + (time - From::clock::now())); +} + +json& ensure_array(json& object, const char* key) +{ + json& array = object[key]; + + if (!array.is_array()) + { + array = json::array(); + } + + return array; +} + +}; // namespace + +EggTVData::EggTVData() : favorites_(ensure_array(favoritesFile_, "favorites")) +{ + try + { + cache_folders(); + } + catch (const fs::filesystem_error& ex) + { + print_error("{}", ex); + } +} + +json EggTVData::cache_favorites() const +{ + json object; + + try + { + std::ifstream(favoritesPath_) >> object; + } + catch (const std::exception& ex) + { + print_error("{}", ex.what()); + } + + return object; +} + +EggTVData::Folder::Cache::Cache(Folder& folder) : folder_(folder) +{ + try + { + for (const fs::directory_entry& entry : fs::directory_iterator(folder_.path())) + { + if (!entry.is_regular_file()) + { + continue; + } + + try + { + replays_.emplace_back( + *this, + entry.path().filename(), + time_point_conv(entry.last_write_time()) + ); + } + catch (const fs::filesystem_error& ex) + { + print_error("{}", ex); + } + } + } + catch (const fs::filesystem_error& ex) + { + print_error("{}", ex); + } + + auto predicate = [](const ReplayRef& a, const ReplayRef& b) + { + // Favorites come first + if (a.favorited() != b.favorited()) + { + return a.favorited(); + } + + return a.time() > b.time(); // sort newest to oldest + }; + + std::sort(replays_.begin(), replays_.end(), predicate); + + // Refresh folder size + folder_.size_ = replays_.size(); +} + +std::shared_ptr EggTVData::Folder::Cache::replay(std::size_t idx) +{ + if (idx >= size()) + { + return {}; + } + + return replays_[idx].replay(); +} + +void EggTVData::Folder::Cache::release(const ReplayRef& ref) +{ + const auto& it = std::find_if(replays_.begin(), replays_.end(), [&ref](const ReplayRef& b) { return &b == &ref; }); + + SRB2_ASSERT(it != replays_.end()); + + replays_.erase(it); + folder_.size_--; +} + +EggTVData::Folder::Folder(EggTVData& tv, const fs::directory_entry& entry) : + size_(std::distance(fs::directory_iterator(entry.path()), fs::directory_iterator())), + time_(time_point_conv(entry.last_write_time())), + tv_(&tv), + name_(entry.path().filename().string()) +{ + SRB2_ASSERT(entry.path().parent_path() == tv_->root_); +} + +EggTVData::Replay::Title::operator const std::string() const +{ + return second().empty() ? first() : fmt::format("{} - {}", first(), second()); +} + +EggTVData::Replay::Replay(Folder::Cache::ReplayRef& ref) : ref_(&ref) +{ + const fs::path path = this->path(); + + menudemo_t info = {}; + + if (path.native().size() >= sizeof info.filepath) + { + return; + } + + std::copy_n(path.string().c_str(), path.native().size() + 1, info.filepath); + + G_LoadDemoInfo(&info); + + if (info.type != MD_LOADED) + { + return; + } + + { + constexpr std::string_view kDelimiter = " - "; + + const std::string_view str = info.title; + const std::size_t mid = str.find(kDelimiter); + + title_ = Title(str.substr(0, mid), mid == std::string::npos ? "" : str.substr(mid + kDelimiter.size())); + //title_ = Title("WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", "WWWWWWWWWWWWWWWWWWWWWWWWWWW"); + } + + map_ = info.map; + + if (info.gametype == GT_RACE) + { + gametype_ = Gametype(GT_RACE, Gametype::Race { + info.numlaps, + kartspeed_cons_t[(info.kartspeed & ~(DF_ENCORE)) + 1].strvalue, + (info.kartspeed & DF_ENCORE) != 0, + }); + } + else + { + gametype_ = Gametype(info.gametype); + } + + for (const auto& data : info.standings) + { + if (!data.ranking) + { + break; + } + + Standing& standing = standings_.emplace_back(); + + standing.name = data.name; + + if (data.skin < numskins) + { + standing.skin = data.skin; + } + + standing.color = data.color; + + if (data.timeorscore != UINT32_MAX - 1) // NO CONTEST + { + if (gametype_.ranks_time()) + { + standing.time = data.timeorscore; + } + else if (gametype_.ranks_points()) + { + standing.score = data.timeorscore; + } + } + } + + invalid_ = false; +} + +EggTVData::Replay::~Replay() +{ + if (!erased_) + { + return; + } + + // Delayed erase function ensures there are no references + // left before ReplayRef is removed from cache. + + try + { + fs::remove(path()); + } + catch (const fs::filesystem_error& ex) + { + // catch inside loop so individual errors don't bail completely + print_error("{}", ex); + } + + // Clear deleted replays from favorites too! + if (favorited()) + { + toggle_favorite(); + } + + ref_->cache().release(*ref_); +} + +void EggTVData::Replay::toggle_favorite() const +{ + const auto& it = ref_->iterator_to_favorite(); + + if (it != ref_->favorites().end()) + { + ref_->favorites().erase(it); + } + else + { + ref_->favorites().emplace_back(ref_->favorites_path()); + } + + ref_->cache().folder().tv().save_favorites(); +} + +void EggTVData::cache_folders() +{ + for (const fs::directory_entry& entry : fs::directory_iterator(root_)) + { + try + { + Folder folder(*this, entry); + + if (!folder.empty()) + { + folders_.push_back(folder); + } + } + catch (const fs::filesystem_error& ex) + { + // catch inside loop so individual errors don't bail completely + print_error("{}", ex); + } + } + + sort_folders(); +} + +void EggTVData::sort_folders() +{ + auto predicate = [this](const Folder& a, const Folder& b) + { + switch (folderSort_) + { + case FolderSort::kDate: + return a.time() > b.time(); + + case FolderSort::kName: + return a.name() < b.name(); // ascending order + + case FolderSort::kSize: + return a.size() > b.size(); + } + + return false; + }; + + std::sort(folders_.begin(), folders_.end(), predicate); +} + +void EggTVData::save_favorites() const +{ + try + { + std::ofstream(favoritesPath_) << favoritesFile_; + } + catch (const std::exception& ex) + { + print_error("{}", ex.what()); + } +} diff --git a/src/menus/class-egg-tv/EggTVData.hpp b/src/menus/class-egg-tv/EggTVData.hpp new file mode 100644 index 000000000..b898ee45a --- /dev/null +++ b/src/menus/class-egg-tv/EggTVData.hpp @@ -0,0 +1,268 @@ +// DR. ROBOTNIK'S RING RACERS +//----------------------------------------------------------------------------- +// Copyright (C) 2023 by James Robert Roman +// +// This program is free software distributed under the +// terms of the GNU General Public License, version 2. +// See the 'LICENSE' file for more details. +//----------------------------------------------------------------------------- + +#ifndef __EGGTVDATA_HPP__ +#define __EGGTVDATA_HPP__ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../../cxxutil.hpp" + +#include "../../d_main.h" // srb2home +#include "../../doomstat.h" // gametype_t +#include "../../doomtype.h" + +namespace srb2::menus::egg_tv +{ + +class EggTVData +{ +private: + const std::filesystem::path root_ = std::filesystem::path{srb2home} / "media/replay/online"; + const std::filesystem::path favoritesPath_ = root_ / "favorites.json"; + + nlohmann::json favoritesFile_ = cache_favorites(); + nlohmann::json& favorites_; + + nlohmann::json cache_favorites() const; + + void cache_folders(); + void save_favorites() const; + +public: + using time_point_t = std::chrono::time_point; + + explicit EggTVData(); + + class Replay; + + class Folder + { + public: + class Cache + { + public: + class ReplayRef + { + public: + explicit ReplayRef(Cache& cache, std::filesystem::path filename, time_point_t time) : + cache_(&cache), filename_(filename), time_(time) + { + } + + Cache& cache() const { return *cache_; } + const std::filesystem::path& filename() const { return filename_; } + const time_point_t& time() const { return time_; } + + std::shared_ptr replay() + { + SRB2_ASSERT(!released_); // do not call after released + + if (!replay_) + { + replay_ = std::make_shared(*this); + } + + return replay_; + } + + void release() + { + replay_.reset(); + released_ = true; + } + + bool favorited() const { return iterator_to_favorite() != favorites().end(); } + nlohmann::json& favorites() const { return cache().folder().tv().favorites_; } + + std::string favorites_path() const + { + // path::generic_string converts to forward + // slashes on Windows. This should suffice to make + // the JSON file portable across installations. + return (std::filesystem::path{cache().folder().name()} / filename()).generic_string(); + } + + nlohmann::json::const_iterator iterator_to_favorite() const + { + return std::find(favorites().begin(), favorites().end(), favorites_path()); + } + + private: + Cache* cache_; + std::filesystem::path filename_; + time_point_t time_; + std::shared_ptr replay_; + bool released_ = false; + }; + + explicit Cache(Folder& folder); + + std::shared_ptr replay(std::size_t idx); + void release(const ReplayRef& ref); + + std::size_t size() const { return folder_.size(); } + Folder& folder() const { return folder_; } + + private: + Folder& folder_; + std::vector replays_; + }; + + explicit Folder(EggTVData& tv, const std::filesystem::directory_entry& entry); + + int x = 0; + int y = 0; + + bool empty() { return size() == 0; } + std::filesystem::path path() const { return tv_->root_ / name_; } + + EggTVData& tv() const { return *tv_; } + + std::size_t size() const { return size_; } + const time_point_t& time() const { return time_; } + const std::string& name() const { return name_; } + + std::unique_ptr load() { return std::make_unique(*this); }; + + bool operator ==(const Folder& b) const { return this == &b; } + + private: + std::size_t size_; + time_point_t time_; + EggTVData* tv_; + std::string name_; + }; + + class Replay + { + public: + class Title + { + public: + explicit Title() {} + explicit Title(const std::string_view& first, const std::string_view& second) : + first_(first), second_(second) + { + } + + const std::string& first() const { return first_; } + const std::string& second() const { return second_; } + + operator const std::string() const; + + private: + std::string first_, second_; + }; + + struct Standing + { + std::string name; + std::optional skin; + std::size_t color; + std::optional time; + std::optional score; + }; + + class Gametype + { + public: + struct Race + { + int laps; + std::string_view speed; + bool encore; + }; + + explicit Gametype() {} + explicit Gametype(INT16 gt, Race race) : gametype_(get(gt)), var_(race) {} + explicit Gametype(INT16 gt) : gametype_(get(gt)) {} + + bool valid() const { return gametype_; } + + std::string_view name() const { return valid() ? gametype_->name : ""; } + UINT32 rules() const { return valid() ? gametype_->rules : 0u; } + + bool ranks_time() const { return !ranks_points(); } + bool ranks_points() const { return rules() & GTR_POINTLIMIT; } + + const Race* race() const { return std::get_if(&var_); } + + private: + const gametype_t* gametype_ = nullptr; + std::variant var_; + + static gametype_t* get(INT16 gt) { return gt >= 0 && gt < numgametypes ? gametypes[gt] : nullptr; } + }; + + explicit Replay(Folder::Cache::ReplayRef& ref); + ~Replay(); + + void mark_for_deletion() + { + erased_ = true; + ref_->release(); + } + + void toggle_favorite() const; + + bool invalid() const { return invalid_; } + bool favorited() const { return ref_->iterator_to_favorite() != ref_->favorites().end(); } + + std::filesystem::path path() const { return ref_->cache().folder().path() / ref_->filename(); } + const time_point_t& date() const { return ref_->time(); } + + std::size_t map() const { return map_; } + const Title& title() const { return title_; } + const Gametype& gametype() const { return gametype_; } + + const std::vector& standings() const { return standings_; } + const Standing* winner() const { return standings_.empty() ? nullptr : &standings_.front(); } + + private: + Folder::Cache::ReplayRef* ref_; + + bool invalid_ = true; + bool erased_ = false; + + std::vector standings_; + std::size_t map_; + Title title_; + Gametype gametype_; + }; + + enum class FolderSort + { + kDate, + kName, + kSize, + }; + + std::vector folders_; + std::unique_ptr cache_; + FolderSort folderSort_ = FolderSort::kDate; + + void sort_folders(); +}; + +}; // namsepace srb2::menus::egg_tv + +#endif // __EGGTVDATA_HPP__ diff --git a/src/menus/class-egg-tv/EggTVGraphics.hpp b/src/menus/class-egg-tv/EggTVGraphics.hpp new file mode 100644 index 000000000..643da3b45 --- /dev/null +++ b/src/menus/class-egg-tv/EggTVGraphics.hpp @@ -0,0 +1,161 @@ +// DR. ROBOTNIK'S RING RACERS +//----------------------------------------------------------------------------- +// Copyright (C) 2023 by James Robert Roman +// +// This program is free software distributed under the +// terms of the GNU General Public License, version 2. +// See the 'LICENSE' file for more details. +//----------------------------------------------------------------------------- + +#ifndef __EGGTVGRAPHICS_HPP__ +#define __EGGTVGRAPHICS_HPP__ + +#include +#include +#include + +#include "../../doomdef.h" // skincolornum_t +#include "../../v_draw.hpp" + +namespace srb2::menus::egg_tv +{ + +class EggTVGraphics +{ +public: + struct PatchManager + { + using patch = const char*; + + template + using array = std::array; + + template + struct Animation + { + array data; + + patch animate(int interval) const; + }; + + Animation<2> bg = { + "RHETSCB1", + "RHETSCB2", + }; + + patch mod = "RHTVMOD"; + patch overlay = "RHETTV"; + + patch bar = "RH_BAR"; + + array<2> folder = { + "RHFLDR02", + "RHFLDR03", + }; + + patch dash = "RHMQBR"; + patch grid = "RHGRID"; + + patch empty = "RHTVSQN0"; + + Animation<4> tv = { + "RHTVSTC1", + "RHTVSTC2", + "RHTVSTC3", + "RHTVSTC4", + }; + + std::array, 2> nodata = { + Animation<2> { + "RHTVSQN1", + "RHTVSQN2", + }, + Animation<2> { + "RHTVSQN3", + "RHTVSQN4", + }, + }; + + std::array, 2> corrupt = { + Animation<2> { + "RHTVSQN5", + "RHTVSQN6", + }, + Animation<2> { + "RHTVSQN7", + "RHTVSQN8", + }, + }; + + patch select = "RHTVSQSL"; + + std::unordered_map gametype = { + {"Race", "RHGT1"}, + {"Battle", "RHGT2"}, + + // TODO: requires support in the demo format + //{"Prisons", "RHGT3"}, + //{"Special", "RHGT4"}, + }; + + patch fav = "RHFAV"; + + patch button = "RHMNBR"; + + struct + { + struct + { + patch up = "RHSBRU"; + patch down = "RHSBRD"; + } + arrow; + + struct + { + patch top = "RHSBR01"; + patch mid = "RHSBR02"; + patch bottom = "RHSBR03"; + } + bead; + } + scroll; + }; + + struct ColorManager + { + using color = skincolornum_t; + + template + using array = std::array; + + array<2> bar = { + SKINCOLOR_JET, + SKINCOLOR_BANANA, + }; + + array<2> folder = { + SKINCOLOR_NONE, + SKINCOLOR_THUNDER, + }; + + array<2> button = { + SKINCOLOR_BLACK, + SKINCOLOR_THUNDER, + }; + + color scroll = SKINCOLOR_THUNDER; + + array<2> select = { + SKINCOLOR_NONE, + SKINCOLOR_MAROON, + }; + }; + + static const PatchManager patches_; + static const ColorManager colors_; +}; + +}; // namespace srb2::menus::egg_tv + +#endif // __EGGTVGRAPHICS_HPP__ diff --git a/src/menus/extras-1.c b/src/menus/extras-1.c index 21cd2337e..8fe411196 100644 --- a/src/menus/extras-1.c +++ b/src/menus/extras-1.c @@ -24,7 +24,7 @@ menuitem_t EXTRAS_Main[] = NULL, {.routine = M_Statistics}, 0, 0}, {IT_STRING | IT_CALL, NULL, NULL, - NULL, {.routine = M_ReplayHut}, 0, 0}, + NULL, {.routine = M_EggTV}, 0, 0}, {IT_STRING | IT_CALL, NULL, NULL, NULL, {.routine = M_SoundTest}, 0, 0}, diff --git a/src/menus/extras-egg-tv.cpp b/src/menus/extras-egg-tv.cpp new file mode 100644 index 000000000..ebf5d3b5f --- /dev/null +++ b/src/menus/extras-egg-tv.cpp @@ -0,0 +1,131 @@ +/// \brief Extras Menu: Egg TV + +#include "class-egg-tv/EggTV.hpp" + +#include "../k_menu.h" +#include "../s_sound.h" + +using namespace srb2::menus::egg_tv; + +namespace +{ + +std::unique_ptr g_egg_tv; + +void M_DrawEggTV() +{ + g_egg_tv->draw(); +} + +boolean M_QuitEggTV() +{ + g_egg_tv = {}; + + return true; +} + +boolean M_HandleEggTV(INT32 choice) +{ + (void)choice; + + const UINT8 pid = 0; + const EggTV::InputReaction reaction = g_egg_tv->input(pid); + + if (reaction.bypass) + { + return false; + } + + if (reaction.effect) + { + S_StartSound(nullptr, reaction.sound); + M_SetMenuDelay(pid); + } + + return true; +} + +void M_DeleteReplayChoice(INT32 choice) +{ + if (choice == MA_YES) + { + g_egg_tv->erase(); + + //S_StartSound(nullptr, sfx_s3k4e); // BOOM + S_StartSound(nullptr, sfx_monch); // :) + } +} + +void M_DeleteReplay(INT32 c) +{ + (void)c; + M_StartMessage( + "Are you sure you want to\n" + "delete this replay?\n" + "\n" + "\x85" "This cannot be undone.\n" "\x80" + "\n" + "Press (A) to confirm or (B) to cancel", + FUNCPTRCAST(M_DeleteReplayChoice), + MM_YESNO + ); + S_StartSound(nullptr, sfx_s3k36); // lel skid +} + +void M_FavoriteReplay(INT32 c) +{ + (void)c; + + g_egg_tv->toggle_favorite(); + + S_StartSound(nullptr, sfx_s1c9); +} + +}; // namespace + +// extras menu: replay hut +menuitem_t EXTRAS_EggTV[] = +{ + {IT_STRING | IT_CALL, "WATCH REPLAY", NULL, NULL, {.routine = [](auto) { g_egg_tv->watch(); }}, 0, 0}, + {IT_STRING | IT_CALL, "STANDINGS", NULL, NULL, {.routine = [](auto) { g_egg_tv->standings(); }}, 0, 0}, + {IT_STRING | IT_CALL, "FAVORITE", NULL, NULL, {.routine = M_FavoriteReplay}, 0, 0}, + + {IT_SPACE}, + + {IT_STRING | IT_CALL, "DELETE REPLAY", NULL, NULL, {.routine = M_DeleteReplay}, 0, 0}, + + {IT_SPACE}, + + {IT_STRING | IT_CALL, "GO BACK", NULL, NULL, {.routine = [](auto) { g_egg_tv->back(); }}, 0, 0}, +}; + +menu_t EXTRAS_EggTVDef = +{ + sizeof (EXTRAS_EggTV)/sizeof (menuitem_t), + &EXTRAS_MainDef, + 0, + EXTRAS_EggTV, + 30, 80, + 0, 0, + 0, + "REPLAY", // music + 41, 1, + M_DrawEggTV, + NULL, + NULL, + M_QuitEggTV, + M_HandleEggTV +}; + +// Call this to construct Egg TV menu +void M_EggTV(INT32 choice) +{ + g_egg_tv = std::make_unique(); + + M_SetupNextMenu(&EXTRAS_EggTVDef, false); +} + +void M_EggTV_RefreshButtonLabels() +{ + EXTRAS_EggTV[2].text = g_egg_tv->favorited() ? "UNFAVORITE" : "FAVORITE"; +} From 3bc7b48b7e5dd37bb451527308a185650753a480 Mon Sep 17 00:00:00 2001 From: James R Date: Wed, 26 Apr 2023 14:28:42 -0700 Subject: [PATCH 3/8] Remove Replay Hut code --- src/g_demo.h | 1 - src/k_menu.h | 14 - src/k_menudraw.c | 402 ----------------------------- src/k_menufunc.c | 10 - src/menus/CMakeLists.txt | 1 - src/menus/extras-replay-hut.c | 296 --------------------- src/menus/transient/pause-replay.c | 6 +- 7 files changed, 3 insertions(+), 727 deletions(-) delete mode 100644 src/menus/extras-replay-hut.c diff --git a/src/g_demo.h b/src/g_demo.h index a10ce88ad..f9e1b0c84 100644 --- a/src/g_demo.h +++ b/src/g_demo.h @@ -47,7 +47,6 @@ struct demovars_s { boolean rewinding; // Rewind in progress boolean loadfiles, ignorefiles; // Demo file loading options - boolean inreplayhut; // Go back to replayhut after demos boolean quitafterplaying; // quit after playing a demo from cmdline boolean deferstart; // don't start playing demo right away boolean netgame; // multiplayer netgame diff --git a/src/k_menu.h b/src/k_menu.h index 675abe982..37a805dc6 100644 --- a/src/k_menu.h +++ b/src/k_menu.h @@ -405,11 +405,6 @@ extern menu_t OPTIONS_DataProfileEraseDef; extern menuitem_t EXTRAS_Main[]; extern menu_t EXTRAS_MainDef; -extern menuitem_t EXTRAS_ReplayHut[]; -extern menu_t EXTRAS_ReplayHutDef; - -extern menuitem_t EXTRAS_ReplayStart[]; -extern menu_t EXTRAS_ReplayStartDef; extern menuitem_t EXTRAS_EggTV[]; extern menu_t EXTRAS_EggTVDef; @@ -1036,11 +1031,6 @@ void M_ExtrasTick(void); boolean M_ExtrasInputs(INT32 ch); boolean M_ExtrasQuit(void); // resets buttons when you quit -// Extras: Replay Hut -void M_HandleReplayHutList(INT32 choice); -boolean M_QuitReplayHut(void); -void M_HutStartReplay(INT32 choice); -void M_PrepReplayList(void); // Extras: Egg TV void M_EggTV(INT32 choice); @@ -1090,8 +1080,6 @@ void M_PlaybackAdjustView(INT32 choice); void M_PlaybackToggleFreecam(INT32 choice); void M_PlaybackQuit(INT32 choice); -void M_ReplayHut(INT32 choice); - // Misc menus: #define numaddonsshown 4 void M_Addons(INT32 choice); @@ -1156,8 +1144,6 @@ extern tic_t shitsfree; // Extras menu: void M_DrawExtrasMovingButton(void); void M_DrawExtras(void); -void M_DrawReplayHut(void); -void M_DrawReplayStartMenu(void); // Misc menus: #define LOCATIONSTRING1 "Visit \x83SRB2.ORG/MODS\x80 to get & make addons!" diff --git a/src/k_menudraw.c b/src/k_menudraw.c index 06d8e95d0..8286a265e 100644 --- a/src/k_menudraw.c +++ b/src/k_menudraw.c @@ -4160,408 +4160,6 @@ void M_DrawPlaybackMenu(void) } -// replay hut... -// ...dear lord this is messy, but Ima be real I ain't fixing this. - -#define SCALEDVIEWWIDTH (vid.width/vid.dupx) -#define SCALEDVIEWHEIGHT (vid.height/vid.dupy) -static void M_DrawReplayHutReplayInfo(menudemo_t *demoref) -{ - patch_t *patch = NULL; - UINT8 *colormap; - INT32 x, y; - - switch (demoref->type) - { - case MD_NOTLOADED: - V_DrawCenteredString(160, 40, 0, "Loading replay information..."); - break; - - case MD_INVALID: - V_DrawCenteredString(160, 40, warningflags, "This replay cannot be played."); - break; - - case MD_SUBDIR: - break; // Can't think of anything to draw here right now - - case MD_OUTDATED: - V_DrawThinString(17, 64, V_ALLOWLOWERCASE|V_TRANSLUCENT|highlightflags, "Recorded on an outdated version."); - /* FALLTHRU */ - default: - // Draw level stuff - x = 15; y = 15; - - K_DrawMapThumbnail( - x<kartspeed & DF_ENCORE) ? V_FLIP : 0), - demoref->map, - NULL); - - if (demoref->kartspeed & DF_ENCORE) - { - static angle_t rubyfloattime = 0; - const fixed_t rubyheight = FINESINE(rubyfloattime>>ANGLETOFINESHIFT); - V_DrawFixedPatch((x+40)<map < nummapheaders && mapheaderinfo[demoref->map]) - { - char *title = G_BuildMapTitle(demoref->map+1); - V_DrawString(x, y, 0, title); - Z_Free(title); - } - else - V_DrawString(x, y, V_ALLOWLOWERCASE|V_TRANSLUCENT, "Level is not loaded."); - - if (demoref->numlaps) - V_DrawThinString(x, y+9, V_ALLOWLOWERCASE, va("(%d laps)", demoref->numlaps)); - - { - const char *gtstring; - if (demoref->gametype < 0) - { - gtstring = "Custom (not loaded)"; - } - else - { - gtstring = gametypes[demoref->gametype]->name; - - if ((gametypes[demoref->gametype]->rules & GTR_CIRCUIT)) - gtstring = va("%s (%s)", gtstring, kartspeed_cons_t[(demoref->kartspeed & ~DF_ENCORE) + 1].strvalue); - } - - V_DrawString(x, y+20, V_ALLOWLOWERCASE, gtstring); - } - - if (!demoref->standings[0].ranking) - { - // No standings were loaded! - V_DrawString(x, y+39, V_ALLOWLOWERCASE|V_TRANSLUCENT, "No standings available."); - - break; - } - - V_DrawThinString(x, y+29, highlightflags, "WINNER"); - V_DrawString(x+38, y+30, V_ALLOWLOWERCASE, demoref->standings[0].name); - - if (demoref->gametype >= 0) - { - if (gametypes[demoref->gametype]->rules & GTR_POINTLIMIT) - { - V_DrawThinString(x, y+39, highlightflags, "SCORE"); - } - else - { - V_DrawThinString(x, y+39, highlightflags, "TIME"); - } - - if (demoref->standings[0].timeorscore == (UINT32_MAX-1)) - { - V_DrawThinString(x+32, y+39, 0, "NO CONTEST"); - } - else if (gametypes[demoref->gametype]->rules & GTR_POINTLIMIT) - { - V_DrawString(x+32, y+40, 0, va("%d", demoref->standings[0].timeorscore)); - } - else - { - V_DrawRightAlignedString(x+84, y+40, 0, va("%d'%02d\"%02d", - G_TicsToMinutes(demoref->standings[0].timeorscore, true), - G_TicsToSeconds(demoref->standings[0].timeorscore), - G_TicsToCentiseconds(demoref->standings[0].timeorscore) - )); - } - } - - // Character face! - - // Lat: 08/06/2020: For some reason missing skins have their value set to 255 (don't even ask me why I didn't write this) - // and for an even STRANGER reason this passes the first check below, so we're going to make sure that the skin here ISN'T 255 before we do anything stupid. - - if (demoref->standings[0].skin < numskins) - { - patch = faceprefix[demoref->standings[0].skin][FACE_WANTED]; - colormap = R_GetTranslationColormap( - demoref->standings[0].skin, - demoref->standings[0].color, - GTC_MENUCACHE); - } - else - { - patch = W_CachePatchName("M_NOWANT", PU_CACHE); - colormap = R_GetTranslationColormap( - TC_RAINBOW, - demoref->standings[0].color, - GTC_MENUCACHE); - } - - V_DrawMappedPatch(BASEVIDWIDTH-15 - SHORT(patch->width), y+20, 0, patch, colormap); - - break; - } -} - -void M_DrawReplayHut(void) -{ - INT32 x, y, cursory = 0; - INT16 i; - INT16 replaylistitem = currentMenu->numitems-2; - boolean processed_one_this_frame = false; - - static UINT16 replayhutmenuy = 0; - - M_DrawEggaChannel(); - - // Draw menu choices - x = currentMenu->x; - y = currentMenu->y; - - if (itemOn > replaylistitem) - { - itemOn = replaylistitem; - dir_on[menudepthleft] = sizedirmenu-1; - extrasmenu.replayScrollTitle = 0; extrasmenu.replayScrollDelay = TICRATE; extrasmenu.replayScrollDir = 1; - } - else if (itemOn < replaylistitem) - { - dir_on[menudepthleft] = 0; - extrasmenu.replayScrollTitle = 0; extrasmenu.replayScrollDelay = TICRATE; extrasmenu.replayScrollDir = 1; - } - - if (itemOn == replaylistitem) - { - INT32 maxy; - // Scroll menu items if needed - cursory = y + currentMenu->menuitems[replaylistitem].mvar1 + dir_on[menudepthleft]*10; - maxy = y + currentMenu->menuitems[replaylistitem].mvar1 + sizedirmenu*10; - - if (cursory > maxy - 20) - cursory = maxy - 20; - - if (cursory - replayhutmenuy > SCALEDVIEWHEIGHT-50) - replayhutmenuy += (cursory-SCALEDVIEWHEIGHT-replayhutmenuy + 51)/2; - else if (cursory - replayhutmenuy < 110) - replayhutmenuy += (max(0, cursory-110)-replayhutmenuy - 1)/2; - } - else - replayhutmenuy /= 2; - - y -= replayhutmenuy; - - // Draw static menu items - for (i = 0; i < replaylistitem; i++) - { - INT32 localy = y + currentMenu->menuitems[i].mvar1; - - if (localy < 65) - continue; - - if (i == itemOn) - cursory = localy; - - if ((currentMenu->menuitems[i].status & IT_DISPLAY)==IT_STRING) - V_DrawString(x, localy, 0, currentMenu->menuitems[i].text); - else - V_DrawString(x, localy, highlightflags, currentMenu->menuitems[i].text); - } - - y += currentMenu->menuitems[replaylistitem].mvar1; - - for (i = 0; i < (INT16)sizedirmenu; i++) - { - INT32 localy = y+i*10; - INT32 localx = x; - - if (localy < 65) - continue; - if (localy >= SCALEDVIEWHEIGHT) - break; - - if (extrasmenu.demolist[i].type == MD_NOTLOADED && !processed_one_this_frame) - { - processed_one_this_frame = true; - G_LoadDemoInfo(&extrasmenu.demolist[i]); - } - - if (extrasmenu.demolist[i].type == MD_SUBDIR) - { - localx += 8; - V_DrawScaledPatch(x - 4, localy, 0, W_CachePatchName(dirmenu[i][DIR_TYPE] == EXT_UP ? "M_RBACK" : "M_RFLDR", PU_CACHE)); - } - - if (itemOn == replaylistitem && i == (INT16)dir_on[menudepthleft]) - { - cursory = localy; - - if (extrasmenu.replayScrollDelay) - extrasmenu.replayScrollDelay--; - else if (extrasmenu.replayScrollDir > 0) - { - if (extrasmenu.replayScrollTitle < (V_StringWidth(extrasmenu.demolist[i].title, 0) - (SCALEDVIEWWIDTH - (x<<1)))<<1) - extrasmenu.replayScrollTitle++; - else - { - extrasmenu.replayScrollDelay = TICRATE; - extrasmenu.replayScrollDir = -1; - } - } - else - { - if (extrasmenu.replayScrollTitle > 0) - extrasmenu.replayScrollTitle--; - else - { - extrasmenu.replayScrollDelay = TICRATE; - extrasmenu.replayScrollDir = 1; - } - } - - V_DrawString(localx - (extrasmenu.replayScrollTitle>>1), localy, highlightflags|V_ALLOWLOWERCASE, extrasmenu.demolist[i].title); - } - else - V_DrawString(localx, localy, V_ALLOWLOWERCASE, extrasmenu.demolist[i].title); - } - - // Draw scrollbar - y = sizedirmenu*10 + currentMenu->menuitems[replaylistitem].mvar1 + 30; - if (y > SCALEDVIEWHEIGHT-80) - { - V_DrawFill(BASEVIDWIDTH-4, 75, 4, SCALEDVIEWHEIGHT-80, 159); - V_DrawFill(BASEVIDWIDTH-3, 76 + (SCALEDVIEWHEIGHT-80) * replayhutmenuy / y, 2, (((SCALEDVIEWHEIGHT-80) * (SCALEDVIEWHEIGHT-80))-1) / y - 1, 149); - } - - // Draw the cursor - V_DrawScaledPatch(currentMenu->x - 24, cursory, 0, - W_CachePatchName("M_CURSOR", PU_CACHE)); - V_DrawString(currentMenu->x, cursory, highlightflags, currentMenu->menuitems[itemOn].text); - - // Now draw some replay info! - V_DrawFill(10, 10, 300, 60, 159); - - if (itemOn == replaylistitem) - { - M_DrawReplayHutReplayInfo(&extrasmenu.demolist[dir_on[menudepthleft]]); - } -} - -void M_DrawReplayStartMenu(void) -{ - const char *warning; - UINT8 i; - menudemo_t *demoref = &extrasmenu.demolist[dir_on[menudepthleft]]; - - M_DrawEggaChannel(); - M_DrawGenericMenu(); - -#define STARTY 62-(extrasmenu.replayScrollTitle>>1) - // Draw rankings beyond first - for (i = 1; i < MAXPLAYERS && demoref->standings[i].ranking; i++) - { - patch_t *patch; - UINT8 *colormap; - - V_DrawRightAlignedString(BASEVIDWIDTH-100, STARTY + i*20,highlightflags, va("%2d", demoref->standings[i].ranking)); - V_DrawThinString(BASEVIDWIDTH-96, STARTY + i*20, V_ALLOWLOWERCASE, demoref->standings[i].name); - - if (demoref->standings[i].timeorscore == UINT32_MAX-1) - V_DrawThinString(BASEVIDWIDTH-92, STARTY + i*20 + 9, 0, "NO CONTEST"); - else if (demoref->gametype < 0) - ; - else if (gametypes[demoref->gametype]->rules & GTR_POINTLIMIT) - V_DrawString(BASEVIDWIDTH-92, STARTY + i*20 + 9, 0, va("%d", demoref->standings[i].timeorscore)); - else - V_DrawRightAlignedString(BASEVIDWIDTH-40, STARTY + i*20 + 9, 0, va("%d'%02d\"%02d", - G_TicsToMinutes(demoref->standings[i].timeorscore, true), - G_TicsToSeconds(demoref->standings[i].timeorscore), - G_TicsToCentiseconds(demoref->standings[i].timeorscore) - )); - - // Character face! - - // Lat: 08/06/2020: For some reason missing skins have their value set to 255 (don't even ask me why I didn't write this) - // and for an even STRANGER reason this passes the first check below, so we're going to make sure that the skin here ISN'T 255 before we do anything stupid. - - if (demoref->standings[i].skin < numskins) - { - patch = faceprefix[demoref->standings[i].skin][FACE_RANK]; - colormap = R_GetTranslationColormap( - demoref->standings[i].skin, - demoref->standings[i].color, - GTC_MENUCACHE); - } - else - { - patch = W_CachePatchName("M_NORANK", PU_CACHE); - colormap = R_GetTranslationColormap( - TC_RAINBOW, - demoref->standings[i].color, - GTC_MENUCACHE); - } - - V_DrawMappedPatch(BASEVIDWIDTH-5 - SHORT(patch->width), STARTY + i*20, 0, patch, colormap); - } -#undef STARTY - - // Handle scrolling rankings - if (extrasmenu.replayScrollDelay) - extrasmenu.replayScrollDelay--; - else if (extrasmenu.replayScrollDir > 0) - { - if (extrasmenu.replayScrollTitle < (i*20 - SCALEDVIEWHEIGHT + 100)<<1) - extrasmenu.replayScrollTitle++; - else - { - extrasmenu.replayScrollDelay = TICRATE; - extrasmenu.replayScrollDir = -1; - } - } - else - { - if (extrasmenu.replayScrollTitle > 0) - extrasmenu.replayScrollTitle--; - else - { - extrasmenu.replayScrollDelay = TICRATE; - extrasmenu.replayScrollDir = 1; - } - } - - V_DrawFill(10, 10, 300, 60, 159); - M_DrawReplayHutReplayInfo(demoref); - - V_DrawString(10, 72, highlightflags|V_ALLOWLOWERCASE, demoref->title); - - // Draw a warning prompt if needed - switch (demoref->addonstatus) - { - case DFILE_ERROR_CANNOTLOAD: - warning = "Some addons in this replay cannot be loaded.\nYou can watch anyway, but desyncs may occur."; - break; - - case DFILE_ERROR_NOTLOADED: - case DFILE_ERROR_INCOMPLETEOUTOFORDER: - warning = "Loading addons will mark your game as modified, and Record Attack may be unavailable.\nYou can watch without loading addons, but desyncs may occur."; - break; - - case DFILE_ERROR_EXTRAFILES: - warning = "You have addons loaded that were not present in this replay.\nYou can watch anyway, but desyncs may occur."; - break; - - case DFILE_ERROR_OUTOFORDER: - warning = "You have this replay's addons loaded, but they are out of order.\nYou can watch anyway, but desyncs may occur."; - break; - - default: - return; - } - - V_DrawSmallString(4, BASEVIDHEIGHT-14, V_ALLOWLOWERCASE, warning); -} - // Draw misc menus: // Addons diff --git a/src/k_menufunc.c b/src/k_menufunc.c index fd17477eb..e467ba3b4 100644 --- a/src/k_menufunc.c +++ b/src/k_menufunc.c @@ -481,16 +481,6 @@ menu_t *M_SpecificMenuRestore(menu_t *torestore) M_SetupRaceMenu(-1); M_SetupDifficultyOptions((cupgrid.grandprix == false)); } - else if (torestore == &EXTRAS_ReplayHutDef) - { - // Handle modifications to the folder while playing - M_ReplayHut(0); - - if (demo.inreplayhut == false) - { - torestore = &EXTRAS_MainDef; - } - } else if (torestore == &PLAY_MP_OptSelectDef) { // Ticker init diff --git a/src/menus/CMakeLists.txt b/src/menus/CMakeLists.txt index 918aae4da..d5ef8d60e 100644 --- a/src/menus/CMakeLists.txt +++ b/src/menus/CMakeLists.txt @@ -3,7 +3,6 @@ target_sources(SRB2SDL2 PRIVATE extras-addons.c extras-challenges.c extras-egg-tv.cpp - extras-replay-hut.c extras-statistics.c main-1.c main-profile-select.c diff --git a/src/menus/extras-replay-hut.c b/src/menus/extras-replay-hut.c deleted file mode 100644 index 881304719..000000000 --- a/src/menus/extras-replay-hut.c +++ /dev/null @@ -1,296 +0,0 @@ -/// \file menus/extras-replay-hut.c -/// \brief Extras Menu: Replay Hut - -#include "../k_menu.h" -#include "../filesrch.h" // Addfile -#include "../d_main.h" -#include "../s_sound.h" -#include "../v_video.h" -#include "../z_zone.h" - -// extras menu: replay hut -menuitem_t EXTRAS_ReplayHut[] = -{ - {IT_KEYHANDLER|IT_NOTHING, "", "", // Dummy menuitem for the replay list - NULL, {.routine = M_HandleReplayHutList}, 0, 0}, - - {IT_NOTHING, "", "", // Dummy for handling wrapping to the top of the menu.. - NULL, {NULL}, 0, 0}, -}; - -menu_t EXTRAS_ReplayHutDef = -{ - sizeof (EXTRAS_ReplayHut)/sizeof (menuitem_t), - &EXTRAS_MainDef, - 0, - EXTRAS_ReplayHut, - 30, 80, - 0, 0, - 0, - "REPLAY", - 41, 1, - M_DrawReplayHut, - NULL, - NULL, - NULL, - NULL -}; - -menuitem_t EXTRAS_ReplayStart[] = -{ - {IT_CALL |IT_STRING, "Load Addons and Watch", NULL, - NULL, {.routine = M_HutStartReplay}, 0, 0}, - - {IT_CALL |IT_STRING, "Load Without Addons", NULL, - NULL, {.routine = M_HutStartReplay}, 10, 0}, - - {IT_CALL |IT_STRING, "Watch Replay", NULL, - NULL, {.routine = M_HutStartReplay}, 10, 0}, - - {IT_SUBMENU |IT_STRING, "Go Back", NULL, - NULL, {.submenu = &EXTRAS_ReplayHutDef}, 30, 0}, -}; - - -menu_t EXTRAS_ReplayStartDef = -{ - sizeof (EXTRAS_ReplayStart)/sizeof (menuitem_t), - &EXTRAS_ReplayHutDef, - 0, - EXTRAS_ReplayStart, - 27, 80, - 0, 0, - 0, - "REPLAY", - 41, 1, - M_DrawReplayStartMenu, - NULL, - NULL, - NULL, - NULL -}; - -void M_PrepReplayList(void) -{ - size_t i; - - if (extrasmenu.demolist) - Z_Free(extrasmenu.demolist); - - extrasmenu.demolist = Z_Calloc(sizeof(menudemo_t) * sizedirmenu, PU_STATIC, NULL); - - for (i = 0; i < sizedirmenu; i++) - { - if (dirmenu[i][DIR_TYPE] == EXT_UP) - { - extrasmenu.demolist[i].type = MD_SUBDIR; - sprintf(extrasmenu.demolist[i].title, "UP"); - } - else if (dirmenu[i][DIR_TYPE] == EXT_FOLDER) - { - extrasmenu.demolist[i].type = MD_SUBDIR; - strncpy(extrasmenu.demolist[i].title, dirmenu[i] + DIR_STRING, 64); - } - else - { - extrasmenu.demolist[i].type = MD_NOTLOADED; - snprintf(extrasmenu.demolist[i].filepath, sizeof extrasmenu.demolist[i].filepath, - // 255 = UINT8 limit. dirmenu entries are restricted to this length (see DIR_LEN). - "%s%.255s", menupath, dirmenu[i] + DIR_STRING); - sprintf(extrasmenu.demolist[i].title, "....."); - } - } -} - -void M_ReplayHut(INT32 choice) -{ - (void)choice; - - if (demo.inreplayhut) - { - demo.rewinding = false; - CL_ClearRewinds(); - } - else - { - snprintf(menupath, 1024, "%s"PATHSEP"media"PATHSEP"replay"PATHSEP"online"PATHSEP, srb2home); - menupathindex[(menudepthleft = menudepth-1)] = strlen(menupath); - } - - if (!preparefilemenu(false, true)) - { - M_StartMessage("No replays found.\n\nPress (B)\n", NULL, MM_NOTHING); - demo.inreplayhut = false; - return; - } - else if (!demo.inreplayhut) - { - dir_on[menudepthleft] = 0; - } - - extrasmenu.replayScrollTitle = 0; - extrasmenu.replayScrollDelay = TICRATE; - extrasmenu.replayScrollDir = 1; - - M_PrepReplayList(); - - if (!demo.inreplayhut) - M_SetupNextMenu(&EXTRAS_ReplayHutDef, false); - - demo.inreplayhut = true; -} - -// key handler -void M_HandleReplayHutList(INT32 choice) -{ - - const UINT8 pid = 0; - (void) choice; - - if (menucmd[pid].dpad_ud < 0) - { - if (dir_on[menudepthleft]) - dir_on[menudepthleft]--; - else - return; - //M_PrevOpt(); - - S_StartSound(NULL, sfx_s3k5b); - M_SetMenuDelay(pid); - extrasmenu.replayScrollTitle = 0; extrasmenu.replayScrollDelay = TICRATE; extrasmenu.replayScrollDir = 1; - } - - else if (menucmd[pid].dpad_ud > 0) - { - if (dir_on[menudepthleft] < sizedirmenu-1) - dir_on[menudepthleft]++; - else - return; - //itemOn = 0; // Not M_NextOpt because that would take us to the extra dummy item - - S_StartSound(NULL, sfx_s3k5b); - M_SetMenuDelay(pid); - extrasmenu.replayScrollTitle = 0; extrasmenu.replayScrollDelay = TICRATE; extrasmenu.replayScrollDir = 1; - } - - else if (M_MenuBackPressed(pid)) - { - M_SetMenuDelay(pid); - M_QuitReplayHut(); - } - - else if (M_MenuConfirmPressed(pid)) - { - M_SetMenuDelay(pid); - switch (dirmenu[dir_on[menudepthleft]][DIR_TYPE]) - { - case EXT_FOLDER: - strcpy(&menupath[menupathindex[menudepthleft]],dirmenu[dir_on[menudepthleft]]+DIR_STRING); - if (menudepthleft) - { - menupathindex[--menudepthleft] = strlen(menupath); - menupath[menupathindex[menudepthleft]] = 0; - - if (!preparefilemenu(false, true)) - { - S_StartSound(NULL, sfx_s224); - M_StartMessage(va("%c%s\x80\nThis folder is empty.\n\nPress (B)\n", ('\x80' + (highlightflags>>V_CHARCOLORSHIFT)), M_AddonsHeaderPath()),NULL,MM_NOTHING); - menupath[menupathindex[++menudepthleft]] = 0; - - if (!preparefilemenu(true, true)) - { - M_QuitReplayHut(); - return; - } - } - else - { - S_StartSound(NULL, sfx_s3k5b); - dir_on[menudepthleft] = 1; - M_PrepReplayList(); - } - } - else - { - S_StartSound(NULL, sfx_s26d); - M_StartMessage(va("%c%s\x80\nThis folder is too deep to navigate to!\n\nPress (B)\n", ('\x80' + (highlightflags>>V_CHARCOLORSHIFT)), M_AddonsHeaderPath()),NULL,MM_NOTHING); - menupath[menupathindex[menudepthleft]] = 0; - } - break; - case EXT_UP: - S_StartSound(NULL, sfx_s3k5b); - menupath[menupathindex[++menudepthleft]] = 0; - if (!preparefilemenu(false, true)) - { - M_QuitReplayHut(); - return; - } - M_PrepReplayList(); - break; - default: - M_SetupNextMenu(&EXTRAS_ReplayStartDef, true); - - extrasmenu.replayScrollTitle = 0; - extrasmenu.replayScrollDelay = TICRATE; - extrasmenu.replayScrollDir = 1; - - switch (extrasmenu.demolist[dir_on[menudepthleft]].addonstatus) - { - case DFILE_ERROR_CANNOTLOAD: - // Only show "Watch Replay Without Addons" - EXTRAS_ReplayStart[0].status = IT_DISABLED; - EXTRAS_ReplayStart[1].status = IT_CALL|IT_STRING; - //EXTRAS_ReplayStart[1].alphaKey = 0; - EXTRAS_ReplayStart[2].status = IT_DISABLED; - itemOn = 1; - break; - - case DFILE_ERROR_NOTLOADED: - case DFILE_ERROR_INCOMPLETEOUTOFORDER: - // Show "Load Addons and Watch Replay" and "Watch Replay Without Addons" - EXTRAS_ReplayStart[0].status = IT_CALL|IT_STRING; - EXTRAS_ReplayStart[1].status = IT_CALL|IT_STRING; - //EXTRAS_ReplayStart[1].alphaKey = 10; - EXTRAS_ReplayStart[2].status = IT_DISABLED; - itemOn = 0; - break; - - case DFILE_ERROR_EXTRAFILES: - case DFILE_ERROR_OUTOFORDER: - default: - // Show "Watch Replay" - EXTRAS_ReplayStart[0].status = IT_DISABLED; - EXTRAS_ReplayStart[1].status = IT_DISABLED; - EXTRAS_ReplayStart[2].status = IT_CALL|IT_STRING; - //EXTRAS_ReplayStart[2].alphaKey = 0; - itemOn = 2; - break; - } - } - } -} - -boolean M_QuitReplayHut(void) -{ - if (extrasmenu.demolist) - Z_Free(extrasmenu.demolist); - extrasmenu.demolist = NULL; - - demo.inreplayhut = false; - - M_GoBack(0); - return true; -} - -void M_HutStartReplay(INT32 choice) -{ - (void)choice; - - restoreMenu = &EXTRAS_ReplayHutDef; - - M_ClearMenus(false); - demo.loadfiles = (itemOn == 0); - demo.ignorefiles = (itemOn != 0); - - G_DoPlayDemo(extrasmenu.demolist[dir_on[menudepthleft]].filepath); -} diff --git a/src/menus/transient/pause-replay.c b/src/menus/transient/pause-replay.c index b1a1353a3..52cc4b7e5 100644 --- a/src/menus/transient/pause-replay.c +++ b/src/menus/transient/pause-replay.c @@ -257,10 +257,10 @@ void M_PlaybackQuit(INT32 choice) (void)choice; G_StopDemo(); - if (demo.inreplayhut) - M_StartControlPanel(); - else if (modeattacking) + if (modeattacking) M_EndModeAttackRun(); + else if (restoreMenu) + M_StartControlPanel(); else D_StartTitle(); } From 2a863ab985ec3b3a8b0dd9527fa03530592139da Mon Sep 17 00:00:00 2001 From: James R Date: Fri, 28 Apr 2023 23:49:28 -0700 Subject: [PATCH 4/8] EggTVData::Folder::Folder: use newest timestamp of file inside directory The timestamp of the directory itself would change when replays are deleted. --- src/menus/class-egg-tv/EggTVData.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/menus/class-egg-tv/EggTVData.cpp b/src/menus/class-egg-tv/EggTVData.cpp index 1f1599cbc..184b9475a 100644 --- a/src/menus/class-egg-tv/EggTVData.cpp +++ b/src/menus/class-egg-tv/EggTVData.cpp @@ -174,12 +174,23 @@ void EggTVData::Folder::Cache::release(const ReplayRef& ref) } EggTVData::Folder::Folder(EggTVData& tv, const fs::directory_entry& entry) : - size_(std::distance(fs::directory_iterator(entry.path()), fs::directory_iterator())), - time_(time_point_conv(entry.last_write_time())), tv_(&tv), name_(entry.path().filename().string()) { SRB2_ASSERT(entry.path().parent_path() == tv_->root_); + + time_ = time_point_t::min(); + size_ = 0; + + for (const fs::directory_entry& entry : fs::directory_iterator(entry.path())) + { + const time_point_t t = time_point_conv(entry.last_write_time()); + + if (time_ < t) + time_ = t; + + size_++; + } } EggTVData::Replay::Title::operator const std::string() const From 7b67a697548a10071ef6a4132075b60595da82d9 Mon Sep 17 00:00:00 2001 From: James R Date: Sat, 29 Apr 2023 00:01:43 -0700 Subject: [PATCH 5/8] EggTVData::Folder::Cache::Cache: move is_regular_file call within try-catch --- src/menus/class-egg-tv/EggTVData.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/menus/class-egg-tv/EggTVData.cpp b/src/menus/class-egg-tv/EggTVData.cpp index 184b9475a..ad2b74fad 100644 --- a/src/menus/class-egg-tv/EggTVData.cpp +++ b/src/menus/class-egg-tv/EggTVData.cpp @@ -112,13 +112,13 @@ EggTVData::Folder::Cache::Cache(Folder& folder) : folder_(folder) { for (const fs::directory_entry& entry : fs::directory_iterator(folder_.path())) { - if (!entry.is_regular_file()) - { - continue; - } - try { + if (!entry.is_regular_file()) + { + continue; + } + replays_.emplace_back( *this, entry.path().filename(), From f94aef481113ad1d0def1cbf636aeb667c93d22b Mon Sep 17 00:00:00 2001 From: James R Date: Sat, 29 Apr 2023 00:04:37 -0700 Subject: [PATCH 6/8] EggTVData::cache_folders: check that file is directory before iterating --- src/menus/class-egg-tv/EggTVData.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/menus/class-egg-tv/EggTVData.cpp b/src/menus/class-egg-tv/EggTVData.cpp index ad2b74fad..1f5f87a76 100644 --- a/src/menus/class-egg-tv/EggTVData.cpp +++ b/src/menus/class-egg-tv/EggTVData.cpp @@ -328,6 +328,11 @@ void EggTVData::cache_folders() { try { + if (!entry.is_directory()) + { + continue; + } + Folder folder(*this, entry); if (!folder.empty()) From 5baa7c14fb44d88ff91719fa66ca53e567717238 Mon Sep 17 00:00:00 2001 From: James R Date: Sat, 29 Apr 2023 00:12:40 -0700 Subject: [PATCH 7/8] EggTVData::cache_favorites: silently fail if favorites file does not exist --- src/menus/class-egg-tv/EggTVData.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/menus/class-egg-tv/EggTVData.cpp b/src/menus/class-egg-tv/EggTVData.cpp index 1f5f87a76..78078e9c5 100644 --- a/src/menus/class-egg-tv/EggTVData.cpp +++ b/src/menus/class-egg-tv/EggTVData.cpp @@ -96,7 +96,12 @@ json EggTVData::cache_favorites() const try { - std::ifstream(favoritesPath_) >> object; + std::ifstream f(favoritesPath_); + + if (f.is_open()) + { + f >> object; + } } catch (const std::exception& ex) { From ab7691508ee5921403733112db3a167f6e07e30c Mon Sep 17 00:00:00 2001 From: James R Date: Sat, 29 Apr 2023 01:15:30 -0700 Subject: [PATCH 8/8] EggTV::watch: load addons --- src/menus/class-egg-tv/EggTV.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/menus/class-egg-tv/EggTV.cpp b/src/menus/class-egg-tv/EggTV.cpp index 557cfc793..a2cd99e42 100644 --- a/src/menus/class-egg-tv/EggTV.cpp +++ b/src/menus/class-egg-tv/EggTV.cpp @@ -439,6 +439,9 @@ void EggTV::watch() const M_ClearMenus(false); + demo.loadfiles = true; + demo.ignorefiles = false; + G_DoPlayDemo(replay->path().string().c_str()); } }