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"; +}