From 0f1a457a8790a7e1532583f1d1c14523c0a196b3 Mon Sep 17 00:00:00 2001 From: itszn Date: Mon, 14 Sep 2020 15:06:06 -0500 Subject: [PATCH] Add support for kco course files --- Beatmap/include/Beatmap/KShootMap.hpp | 4 +- Beatmap/src/KShootMap.cpp | 86 +++++++++++ Beatmap/src/MapDatabase.cpp | 183 +++++++++++++++++++--- Main/include/ChallengeSelect.hpp | 61 +++++++- Main/include/Game.hpp | 2 + Main/src/ChallengeSelect.cpp | 212 ++++++++++++++++++++++++-- Main/src/Game.cpp | 121 ++++++++++++++- Shared/include/Shared/Files.hpp | 16 +- Shared/src/Unix/Files.cpp | 55 +++++-- Shared/src/Windows/Files.cpp | 58 +++++-- 10 files changed, 718 insertions(+), 80 deletions(-) diff --git a/Beatmap/include/Beatmap/KShootMap.hpp b/Beatmap/include/Beatmap/KShootMap.hpp index 838d88070..f8fe63e38 100644 --- a/Beatmap/include/Beatmap/KShootMap.hpp +++ b/Beatmap/include/Beatmap/KShootMap.hpp @@ -88,4 +88,6 @@ class KShootMap private: static const char* c_sep; -}; \ No newline at end of file +}; + +bool ParseKShootCourse(BinaryStream& input, Map& settings, Vector& charts); \ No newline at end of file diff --git a/Beatmap/src/KShootMap.cpp b/Beatmap/src/KShootMap.cpp index 6ddc330d1..777424779 100644 --- a/Beatmap/src/KShootMap.cpp +++ b/Beatmap/src/KShootMap.cpp @@ -65,6 +65,92 @@ const KShootBlock& KShootMap::TickIterator::GetCurrentBlock() const return *m_currentBlock; } +bool ParseKShootCourse(BinaryStream& input, Map& settings, Vector& charts) +{ + StringEncoding chartEncoding = StringEncoding::Unknown; + + // Read Byte Order Mark + uint32_t bom = 0; + input.Serialize(&bom, 3); + + // If the BOM is not present, the chart might not be UTF-8. + // This is forbidden by the spec, but there are old charts which did not use UTF-8. (#314) + if (bom == 0x00bfbbef) + { + chartEncoding = StringEncoding::UTF8; + } + else + { + input.Seek(0); + } + + uint32_t lineNumber = 0; + String line; + static const String lineEnding = "\r\n"; + + // Parse header (encoding-agnostic) + while(TextStream::ReadLine(input, line, lineEnding)) + { + line.Trim(); + lineNumber++; + if(line == "--") + { + break; + } + + String k, v; + if (line.empty()) + continue; + if (line.substr(0, 2).compare("//") == 0) + continue; + if(!line.Split("=", &k, &v)) + return false; + + settings.FindOrAdd(k) = v; + } + + if (chartEncoding == StringEncoding::Unknown) + { + chartEncoding = StringEncodingDetector::Detect(input, 0, input.Tell()); + + if (chartEncoding != StringEncoding::Unknown) + Logf("Course encoding is assumed to be %s", Logger::Severity::Info, GetDisplayString(chartEncoding)); + else + Log("Course encoding couldn't be assumed. (Assuming UTF-8)", Logger::Severity::Warning); + + } + if (chartEncoding != StringEncoding::Unknown) + { + for (auto& it : settings) + { + const String& value = it.second; + if (value.empty()) continue; + + it.second = StringEncodingConverter::ToUTF8(chartEncoding, value); + } + } + + while (TextStream::ReadLine(input, line, lineEnding)) + { + line.Trim(); + lineNumber++; + if (line.empty() || line[0] != '[') + continue; + + line.TrimFront('['); + line.TrimBack(']'); + if (line.empty()) + { + Logf("Empty course chart found on line %u", Logger::Severity::Warning, lineNumber); + return false; + } + + line = Path::Normalize(line); + charts.push_back(line); + } + return true; +} + KShootMap::KShootMap() { diff --git a/Beatmap/src/MapDatabase.cpp b/Beatmap/src/MapDatabase.cpp index 4b6aa59ca..25e9ec11a 100644 --- a/Beatmap/src/MapDatabase.cpp +++ b/Beatmap/src/MapDatabase.cpp @@ -6,6 +6,7 @@ #include "Shared/Profiling.hpp" #include "Shared/Files.hpp" #include "Shared/Time.hpp" +#include "KShootMap.hpp" #include #include #include @@ -461,6 +462,25 @@ class MapDatabase_Impl return std::move(changes); } + // TODO(itszn) make this not case sensitive + ChartIndex* FindFirstChartByPath(const String& searchString) + { + String stmt = "SELECT DISTINCT rowid FROM Charts WHERE path LIKE \"%" + searchString + "%\""; + + Map res; + DBStatement search = m_database.Query(stmt); + while(search.StepRow()) + { + int32 id = search.IntColumn(0); + ChartIndex** chart = m_charts.Find(id); + if (!chart) + return nullptr; + return *chart; + } + + return nullptr; + } + ChartIndex* FindFirstChartByHash(const String& hash) { ChartIndex** chart = m_chartsByHash.Find(hash); @@ -489,6 +509,7 @@ class MapDatabase_Impl return res; } + // TODO(itszn) make this not case sensitive Map FindFoldersByPath(const String& searchString) { String stmt = "SELECT DISTINCT folderId FROM Charts WHERE path LIKE \"%" + searchString + "%\""; @@ -672,6 +693,7 @@ class MapDatabase_Impl "diff_name=?,diff_shortname=?,bpm=?,diff_index=?,level=?,hash=?,preview_file=?,preview_offset=?,preview_length=?,lwt=? WHERE rowid=?"); //TODO: update DBStatement updateChallenge = m_database.Query("UPDATE Challenges SET title=?,charts=?,clear_rating=?,req_text=?,path=?,hash=?,level=?,lwt=? WHERE rowid=?"); DBStatement removeChart = m_database.Query("DELETE FROM Charts WHERE rowid=?"); + DBStatement removeChallenge = m_database.Query("DELETE FROM Challenges WHERE rowid=?"); DBStatement removeFolder = m_database.Query("DELETE FROM Folders WHERE rowid=?"); DBStatement scoreScan = m_database.Query("SELECT rowid,score,crit,near,miss,gauge,gameflags,replay,timestamp,user_name,user_id,local_score,window_perfect,window_good,window_hold,window_miss FROM Scores WHERE chart_hash=?"); DBStatement moveScores = m_database.Query("UPDATE Scores set chart_hash=? where chart_hash=?"); @@ -735,8 +757,16 @@ class MapDatabase_Impl chal->charts.push_back(chart); continue; } + chart = FindFirstChartByPath(hash); + if (chart) + { + chal->charts.push_back(chart); + continue; + } + // TODO alternate search by name and level - Logf("Could not find chart hash %s for challenge %s", Logger::Severity::Warning, *(chal->path),*hash); + + Logf("Could not find chart %s for challenge %s", Logger::Severity::Warning, *(chal->path),*hash); chal->missingChart = true; } @@ -779,6 +809,27 @@ class MapDatabase_Impl updatedChalEvents.Add(chal); } } + else if(e.type == Event::Challenge && e.action == Event::Removed) + { + auto itChal = m_challenges.find(e.id); + assert(itChal != m_challenges.end()); + + // TODO if we have seperate score entries for challenges, delete them here + /* + for (auto s : itChal->second->scores) + { + delete s; + } + itChal->second->scores.clear(); + */ + delete itChal->second; + m_challenges.erase(e.id); + + // Remove diff in db + removeChallenge.BindInt(1, e.id); + removeChallenge.Step(); + removeChallenge.Rewind(); + } if(e.type == Event::Chart && e.action == Event::Added) { String folderPath = Path::RemoveLast(e.path, nullptr); @@ -1565,6 +1616,12 @@ class MapDatabase_Impl chal->charts.push_back(chart); continue; } + chart = FindFirstChartByPath(hash); + if (chart) + { + chal->charts.push_back(chart); + continue; + } Logf("Could not find chart hash %s for challenge %s", Logger::Severity::Warning, *(chal->path),*hash); chal->missingChart = true; // TODO alternate search by name and level @@ -1624,21 +1681,34 @@ class MapDatabase_Impl void m_SearchThread() { Map fileList; - + Map challengeFileList; + Map legacyChallengeFileList; { ProfilerScope $("Chart Database - Enumerate Files and Charts"); m_outer.OnSearchStatusUpdated.Call("[START] Chart Database - Enumerate Files and Folders"); for(String rootSearchPath : m_searchPaths) { - Vector files = Files::ScanFilesRecursive(rootSearchPath, "ksh", &m_interruptSearch); + Vector exts(3); + exts[0] = "ksh"; + exts[1] = "chal"; + exts[2] = "kco"; + Map> files = Files::ScanFilesRecursive(rootSearchPath, exts, &m_interruptSearch); if(m_interruptSearch) return; - for(FileInfo& fi : files) + for(FileInfo& fi : files["ksh"]) { fileList.Add(fi.fullPath, fi); } + for(FileInfo& fi : files["chal"]) + { + challengeFileList.Add(fi.fullPath, fi); + } + for(FileInfo& fi : files["kco"]) + { + legacyChallengeFileList.Add(fi.fullPath, fi); + } } - m_outer.OnSearchStatusUpdated.Call("[END] Chart Database - Enumerate Files and Folders for Charts"); + m_outer.OnSearchStatusUpdated.Call("[END] Chart Database - Enumerate Files and Folders"); } { @@ -1765,26 +1835,6 @@ class MapDatabase_Impl } m_outer.OnSearchStatusUpdated.Call(""); - // Look up challenges - // TODO combine this with the first enum to reduce file lookup - Map challengeFileList; - { - ProfilerScope $("Chart Database - Enumerate Files and Folders for Challenges"); - m_outer.OnSearchStatusUpdated.Call("[START] Chart Database - Enumerate Files and Folders"); - for(String rootSearchPath : m_searchPaths) - { - Vector files = Files::ScanFilesRecursive(rootSearchPath, "chal", &m_interruptSearch); - if(m_interruptSearch) - return; - for(FileInfo& fi : files) - { - Logf("Found chal: %s", Logger::Severity::Info, *fi.fullPath); - challengeFileList.Add(fi.fullPath, fi); - } - } - m_outer.OnSearchStatusUpdated.Call("[END] Chart Database - Enumerate Files and Folders for Challenges"); - } - { ProfilerScope $("Chart Database - Process Removed Challenges"); m_outer.OnSearchStatusUpdated.Call("[START] Chart Database - Process Removed Challenges"); @@ -1804,6 +1854,89 @@ class MapDatabase_Impl m_outer.OnSearchStatusUpdated.Call("[END] Chart Database - Process Removed Challenges"); } + if (legacyChallengeFileList.size() > 0) + { + bool addedNewJson = false; + ProfilerScope $("Chart Database - Converting Legacy Challenges"); + for (auto f : legacyChallengeFileList) + { + if (m_paused.load()) + { + unique_lock lock(m_pauseMutex); + m_cvPause.wait(lock); + } + + if (!m_searching) + break; + + String newName = f.first + ".chal"; + // XXX if the kco was modified then after converting, it will not be updated + if (Path::FileExists(newName)) + { + // If we already did a convert, check if the kco has been updated + uint64 mylwt = f.second.lastWriteTime; + FileInfo* conv = challengeFileList.Find(newName); + if (conv != nullptr && conv->lastWriteTime >= mylwt) + { + // No update + continue; + } + } + Logf("Converting legacy KShoot course %s", Logger::Severity::Info, f.first); + + File legacyFile; + if (!legacyFile.OpenRead(f.first)) + { + m_outer.OnSearchStatusUpdated.Call(Utility::Sprintf("Unable to open KShoot course [%s]", f.first)); + continue; + } + + FileReader legacyReader(legacyFile); + Map courseSettings; + Vector courseCharts; + if (!ParseKShootCourse(legacyReader, courseSettings, courseCharts) + || !courseSettings.Contains("title") + || courseCharts.size() == 0) + { + m_outer.OnSearchStatusUpdated.Call(Utility::Sprintf("Skipping corrupted KShoot course [%s]", f.first)); + continue; + } + + nlohmann::json newJson = "{\"charts\":[], \"level\":0, \"global\":{\"clear\":true}}"_json; + newJson["title"] = courseSettings["title"]; + for (const String& chart : courseCharts) + newJson["charts"].push_back(chart); + + File newJsonFile; + if (!newJsonFile.OpenWrite(newName)) + { + m_outer.OnSearchStatusUpdated.Call(Utility::Sprintf("Unable to open KShoot course json file [%s]", newName)); + continue; + } + String jsonData = newJson.dump(4); + newJsonFile.Write(*jsonData, jsonData.length()); + addedNewJson = true; + } + + // If we added a json file we have to rescan for chals, this only happens when converting legacy courses + if (addedNewJson) + { + for (String rootSearchPath : m_searchPaths) + { + challengeFileList.clear(); + Vector files = Files::ScanFilesRecursive(rootSearchPath, "chal", &m_interruptSearch); + if (m_interruptSearch) + return; + for (FileInfo& fi : files) + { + challengeFileList.Add(fi.fullPath, fi); + } + } + } + m_outer.OnSearchStatusUpdated.Call("[END] Chart Database - Converting Legacy Challenges"); + } + + if (challengeFileList.size() > 0) { ProfilerScope $("Chart Database - Process New Challenges"); m_outer.OnSearchStatusUpdated.Call("[START] Chart Database - Process New Challenges"); diff --git a/Main/include/ChallengeSelect.hpp b/Main/include/ChallengeSelect.hpp index e6cd22452..887ce7540 100644 --- a/Main/include/ChallengeSelect.hpp +++ b/Main/include/ChallengeSelect.hpp @@ -43,6 +43,13 @@ class ChallengeOption : public Optional { return overrider; return *this; } + // Get the value with an default if it is not set + T Get(const T& def) const + { + if (!HasValue()) + return def; + return **this; + } // This will pass if not overriden static ChallengeOption IgnoreOption() { @@ -87,10 +94,17 @@ struct ChallengeOptions{ v(uint32, min_modspeed) \ v(uint32, max_modspeed) \ v(float, hidden_min) \ - v(float, hidden_max) \ + v(float, sudden_min) \ v(uint32, crit_judge) \ v(uint32, near_judge) \ - v(bool, allow_cmod) + v(uint32, hold_judge) \ + v(bool, allow_cmod) \ + v(uint32, average_percentage) \ + v(float, average_gauge) \ + v(uint32, average_errors) \ + v(uint32, average_nears) \ + v(uint32, average_crits) + #define CHALLENGE_OPTION_DEC(t, n) ChallengeOption n; CHALLENGE_OPTIONS_ALL(CHALLENGE_OPTION_DEC); @@ -100,21 +114,28 @@ struct ChallengeOptions{ { ChallengeOptions out; #define CHALLENGE_OPTIONS_MERGE(t, n) out. ##n = n.Merge(overrider. ##n); - CHALLENGE_OPTIONS_ALL(CHALLENGE_OPTIONS_MERGE) + CHALLENGE_OPTIONS_ALL(CHALLENGE_OPTIONS_MERGE); #undef CHALLENGE_OPTIONS_MERGE + return out; + } + void Reset() + { +#define CHALLENGE_OPTION_RESET(t, n) n.Reset(); + CHALLENGE_OPTIONS_ALL(CHALLENGE_OPTION_RESET); +#undef CHALLENGE_OPTION_RESET } #undef CHALLENGE_OPTIONS_ALL }; struct ChallengeRequirements { - bool evaluated = false; #define CHALLENGE_REQS_ALL(v) \ v(bool, clear) \ - v(uint32, min_score) \ + v(uint32, min_percentage) \ v(float, min_gauge) \ v(uint32, min_errors) \ v(uint32, min_nears) \ + v(uint32, min_crits) \ v(uint32, min_chain) #define CHALLENGE_REQ_DEC(t, n) ChallengeRequirement n; CHALLENGE_REQS_ALL(CHALLENGE_REQ_DEC); @@ -123,16 +144,22 @@ struct ChallengeRequirements // if the second set doesn't have an active member, this one is used bool Passed(struct ChallengeRequirements& over) { - assert(evaluated && over.evaluated); #define CHALLENGE_REQ_PASSED_OVERRIDE(t, n) res = res && (over. ##n.PassedOrNull() || n.PassedOrIgnored()); bool res = true; CHALLENGE_REQS_ALL(CHALLENGE_REQ_PASSED_OVERRIDE); return res; #undef CHALLENGE_REQ_PASSED_OVERRIDE } + ChallengeRequirements Merge(const ChallengeRequirements& overrider) + { + ChallengeRequirements out; +#define CHALLENGE_REQS_MERGE(t, n) out. ##n = n.Merge(overrider. ##n); + CHALLENGE_REQS_ALL(CHALLENGE_REQS_MERGE); +#undef CHALLENGE_REQS_MERGE + return out; + } bool Passed() { - assert(evaluated); #define CHALLENGE_REQ_PASSED(t, n) res = res && (n.PassedOrIgnored()); bool res = true; CHALLENGE_REQS_ALL(CHALLENGE_REQ_PASSED); @@ -141,7 +168,6 @@ struct ChallengeRequirements } void Reset() { - evaluated = false; #define CHALLENGE_REQ_RESET(t, n) n.Reset(); CHALLENGE_REQS_ALL(CHALLENGE_REQ_RESET); #undef CHALLENGE_REQ_RESET @@ -154,15 +180,31 @@ class ChallengeManager ChallengeIndex* m_chal = nullptr; ChartIndex* m_currentChart = nullptr; int m_chartIndex = 0; + int m_chartsPlayed = 0; bool m_running = false; Vector m_scores; ChallengeRequirements m_globalReqs; Vector m_reqs; + ChallengeOptions m_globalOpts; + Vector m_opts; + + ChallengeOptions m_currentOptions; + bool m_passedCurrentChart; + bool m_finishedCurrentChart; + + uint64 m_totalNears = 0; + uint64 m_totalErrors = 0; + uint64 m_totalCrits = 0; + uint64 m_totalScore = 0; + uint64 m_totalPercentage = 0; + float m_totalGauge = 0.0; public: bool RunningChallenge() { return m_running; } bool StartChallenge(ChallengeIndex* chal); friend class Game; void ReportScore(Game*, ClearMark); + const ChallengeOptions& GetCurrentOptions() { return m_currentOptions; } + bool ReturnToSelect(); private: ChallengeOption m_getOptionAsPositiveInteger( @@ -176,6 +218,9 @@ class ChallengeManager ChallengeRequirements m_processReqs(nlohmann::json req); ChallengeOptions m_processOptions(nlohmann::json req); + + bool m_setupNextChart(); + bool m_finishedAllCharts(bool passed); }; struct ChallengeSelectIndex diff --git a/Main/include/Game.hpp b/Main/include/Game.hpp index de00a7932..969907016 100644 --- a/Main/include/Game.hpp +++ b/Main/include/Game.hpp @@ -7,6 +7,7 @@ #include "HitStat.hpp" class MultiplayerScreen; +class ChallengeManager; enum class GameFlags : uint32 { @@ -53,6 +54,7 @@ class Game : public IAsyncLoadableApplicationTickable virtual ~Game() = default; static Game* Create(ChartIndex* chart, PlayOptions&& options); static Game* Create(MultiplayerScreen*, ChartIndex* chart, PlayOptions&& options); + static Game* Create(ChallengeManager*, ChartIndex* chart, PlayOptions&& options); static Game* Create(const String& mapPath, PlayOptions&& options); static Game* CreatePractice(ChartIndex* chart, PlayOptions&& options); static GameFlags FlagsFromSettings(); diff --git a/Main/src/ChallengeSelect.cpp b/Main/src/ChallengeSelect.cpp index 62745a276..2094a7de9 100644 --- a/Main/src/ChallengeSelect.cpp +++ b/Main/src/ChallengeSelect.cpp @@ -303,6 +303,8 @@ class ChallengeSelect_Impl : public ChallengeSelect int32 m_lastChalIndex = -1; DBUpdateScreen* m_dbUpdateScreen = nullptr; + + ChallengeManager m_manager; public: ChallengeSelect_Impl() {} @@ -438,6 +440,8 @@ class ChallengeSelect_Impl : public ChallengeSelect { // TODO start the chal logic ChallengeIndex* chal = GetCurrentSelectedChallenge(); + if (m_manager.StartChallenge(chal)) + m_transitionedToGame = true; /* Game *game = Game::Create(chart, Game::FlagsFromSettings()); if (!game) @@ -681,6 +685,11 @@ class ChallengeSelect_Impl : public ChallengeSelect void TickNavigation(float deltaTime) { + if (!m_manager.ReturnToSelect()) + { + return; + } + // Lock mouse to screen when active if (g_gameConfig.GetEnum(GameConfigKeys::LaserInputDevice) == InputDevice::Mouse && g_gameWindow->IsActive()) { @@ -754,12 +763,15 @@ class ChallengeSelect_Impl : public ChallengeSelect virtual void OnRestore() { + // NOTE: we can't trigger the next chart here bc you can't add tickables on restore g_application->DiscordPresenceMenu("Challenge Select"); m_suspended = false; m_hasRestored = true; m_transitionedToGame = false; //m_previewPlayer.Restore(); m_selectionWheel->ResetLuaTables(); + //TODO if the manager is going to trigger in the next tick we probably should not do this + // we could add a delegate for finishing the charts and then use that to restart searching m_mapDatabase->ResumeSearching(); if (g_gameConfig.GetBool(GameConfigKeys::AutoResetSettings)) { @@ -773,6 +785,11 @@ class ChallengeSelect_Impl : public ChallengeSelect } } + + ChallengeIndex* GetCurrentSelectedChallenge() override + { + return m_selectionWheel->GetSelection(); + } }; @@ -877,10 +894,11 @@ ChallengeRequirements ChallengeManager::m_processReqs(nlohmann::json req) { ChallengeRequirements out; out.clear = m_getOptionAsBool(req, "clear"); - out.min_score = m_getOptionAsPositiveInteger(req, "min_percentage", 0, 200); - out.min_gauge = m_getOptionAsFloat(req, "min_gauge"); + out.min_percentage = m_getOptionAsPositiveInteger(req, "min_percentage", 0, 200); + out.min_gauge = m_getOptionAsFloat(req, "min_gauge", 0.0, 1.0); out.min_errors = m_getOptionAsPositiveInteger(req, "min_errors"); - out.min_nears = m_getOptionAsPositiveInteger(req, "min_errors"); + out.min_nears = m_getOptionAsPositiveInteger(req, "min_nears"); + out.min_crits = m_getOptionAsPositiveInteger(req, "min_crits"); out.min_chain = m_getOptionAsPositiveInteger(req, "min_chain"); return out; } @@ -893,18 +911,34 @@ ChallengeOptions ChallengeManager::m_processOptions(nlohmann::json j) out.min_modspeed = m_getOptionAsPositiveInteger(j, "min_modspeed"); out.max_modspeed = m_getOptionAsPositiveInteger(j, "max_modspeed"); out.allow_cmod = m_getOptionAsBool(j, "allow_cmod"); - out.hidden_min = m_getOptionAsFloat(j, "hidden_min"); - out.hidden_max = m_getOptionAsFloat(j, "hidden_max"); + out.hidden_min = m_getOptionAsFloat(j, "hidden_min", 0.0, 1.0); + out.sudden_min = m_getOptionAsFloat(j, "sudden_min", 0.0, 1.0); + out.crit_judge = m_getOptionAsPositiveInteger(j, "crit_judgement", 0, 46); + out.near_judge = m_getOptionAsPositiveInteger(j, "near_judgement", 0, 92); + out.hold_judge = m_getOptionAsPositiveInteger(j, "hold_judgement", 0, 138); + + // These reqs are checked at the end so we keep em as options + // TODO only do this on the global one? (overrides don't work for these) + out.average_percentage = m_getOptionAsPositiveInteger(j, "min_average_percentage", 0, 200); + out.average_gauge = m_getOptionAsFloat(j, "min_average_gauge", 0, 1.0); + out.average_errors = m_getOptionAsPositiveInteger(j, "max_average_errors"); + out.average_nears = m_getOptionAsPositiveInteger(j, "max_average_nears"); + out.average_crits = m_getOptionAsPositiveInteger(j, "min_average_crits"); return out; } bool ChallengeManager::StartChallenge(ChallengeIndex* chal) { + assert(!m_running); + if (chal->missingChart) return false; + // Force reload from file in case it was changed + chal->ReloadSettings(); + // Check if there are valid settings - nlohmann::json settings = m_chal->GetSettings(); + nlohmann::json settings = chal->GetSettings(); // Check if the json loaded correctly if (settings.is_null() || settings.is_discarded()) @@ -912,26 +946,178 @@ bool ChallengeManager::StartChallenge(ChallengeIndex* chal) m_chal = chal; m_running = true; + m_finishedCurrentChart = false; m_scores.clear(); m_reqs.clear(); + m_opts.clear(); + m_chartIndex = 0; + m_chartsPlayed = 0; + + m_totalNears = 0; + m_totalErrors = 0; + m_totalCrits = 0; + m_totalScore = 0; + m_totalPercentage = 0; + m_totalGauge = 0.0; + + m_globalReqs.Reset(); + m_globalOpts.Reset(); + if (settings.contains("global")) + { + m_globalReqs = m_processReqs(settings["global"]); + m_globalOpts = m_processOptions(settings["global"]); + } + + + if (settings.contains("overrides")) + { + nlohmann::json overrides = settings["overrides"]; + + if (m_chal->charts.size() < overrides.size()) + { + Log("Note: more overrides than charts", Logger::Severity::Warning); + } + for (int i = 0; i < overrides.size() && i < m_chal->charts.size(); i++) + { + m_reqs.push_back(m_processReqs(overrides[i])); + m_opts.push_back(m_processOptions(overrides[i])); + } + } + for (int i=m_reqs.size(); icharts.size(); i++) + { + m_reqs.push_back(ChallengeRequirements()); + m_opts.push_back(ChallengeOptions()); + } + + return m_setupNextChart(); +} + +bool ChallengeManager::m_finishedAllCharts(bool passed) +{ + assert(m_chartsPlayed > 0); + + if (m_totalPercentage / m_chartsPlayed < m_globalOpts.average_percentage.Get(0)) + passed = false; + if (m_totalGauge / m_chartsPlayed < m_globalOpts.average_gauge.Get(0.0)) + passed = false; + if (m_totalErrors / m_chartsPlayed > m_globalOpts.average_errors.Get(INT_MAX)) + passed = false; + if (m_totalNears / m_chartsPlayed > m_globalOpts.average_nears.Get(INT_MAX)) + passed = false; + if (m_totalNears / m_chartsPlayed < m_globalOpts.average_crits.Get(0)) + passed = false; + + if (!passed) + { + g_gameWindow->ShowMessageBox("Challenge Failed", "Sorry, you failed the challenge", 0); + } + else + { + g_gameWindow->ShowMessageBox("Challenge Passed", "Congrats! You passed the challenge", 0); + } + + m_running = false; + m_chal = nullptr; + m_currentChart = nullptr; + return true; +} - m_globalReqs = m_processReqs(settings["global"]); +// Returns if it is all done or not +bool ChallengeManager::ReturnToSelect() +{ + if (!m_running) + return true; - nlohmann::json overrides = settings["overrides"]; + if (!m_finishedCurrentChart) + return false; - if (m_chal->charts.size() < overrides.size()) + m_chartsPlayed++; + + if (!m_passedCurrentChart) { - Log("Note: more overrides than charts", Logger::Severity::Warning); + return m_finishedAllCharts(false); } - for (int i=0; icharts.size(); i++) + + m_chartIndex++; + if (m_chartIndex == m_chal->charts.size()) { - m_reqs.push_back(m_processReqs(overrides[i])); + return m_finishedAllCharts(true); } + return m_setupNextChart(); +} + +bool ChallengeManager::m_setupNextChart() +{ + assert(m_running); + + m_passedCurrentChart = false; + m_finishedCurrentChart = false; + m_currentChart = m_chal->charts[m_chartIndex]; + m_currentOptions = m_globalOpts.Merge(m_opts[m_chartIndex]); + + if (m_currentOptions.min_modspeed.Get(0) > + m_currentOptions.max_modspeed.Get(INT_MAX)) + { + Logf("Skipping setting 'max_modspeed': must be more than 'min_modspeed'", Logger::Severity::Warning); + m_currentOptions.max_modspeed = ChallengeOption::IgnoreOption(); + } + + GameFlags flags; + if (m_currentOptions.excessive.Get(false)) + flags = GameFlags::Hard; + else + flags = GameFlags::None; + + if (m_currentOptions.mirror.Get(false)) + flags = flags | GameFlags::Mirror; + + Game* game = Game::Create(this, m_currentChart, flags); + if (!game) + { + Log("Failed to start game", Logger::Severity::Error); + return false; + } + + g_transition->TransitionTo(game); return true; } void ChallengeManager::ReportScore(Game* game, ClearMark clearMark) { + assert(m_running); + + m_finishedCurrentChart = true; + const Scoring& scoring = game->GetScoring(); + + ChallengeRequirements req = m_globalReqs.Merge(m_reqs[m_chartIndex]); + + uint32 finalScore = scoring.CalculateCurrentScore(); + uint32 percentage = (finalScore - 8000000) / 10000; -} \ No newline at end of file + m_totalCrits += scoring.GetPerfects(); + m_totalNears += scoring.GetGoods(); + m_totalErrors += scoring.GetMisses(); + m_totalScore += finalScore; + m_totalPercentage += percentage; + + if (req.clear.Get(false) && clearMark >= ClearMark::NormalClear) + req.clear.MarkPassed(); + + if (req.min_percentage.HasValue() && percentage >= *req.min_percentage) + req.min_percentage.MarkPassed(); + + if (req.min_gauge.HasValue() && scoring.currentGauge >= *req.min_gauge) + req.min_gauge.MarkPassed(); + + if (req.min_errors.HasValue() && scoring.GetMisses() >= *req.min_errors) + req.min_errors.MarkPassed(); + + if (req.min_nears.HasValue() && scoring.GetGoods() >= *req.min_nears) + req.min_nears.MarkPassed(); + + if (req.min_chain.HasValue() && scoring.maxComboCounter >= *req.min_chain) + req.min_chain.MarkPassed(); + + m_passedCurrentChart = req.Passed(); +} diff --git a/Main/src/Game.cpp b/Main/src/Game.cpp index cd275be90..5cca25ca1 100755 --- a/Main/src/Game.cpp +++ b/Main/src/Game.cpp @@ -17,6 +17,7 @@ #include "AudioPlayback.hpp" #include "Input.hpp" #include "SongSelect.hpp" +#include "ChallengeSelect.hpp" #include "ScoreScreen.hpp" #include "TransitionScreen.hpp" #include "AsyncAssetLoader.hpp" @@ -74,6 +75,7 @@ class Game_Impl : public Game bool m_renderDebugHUD = false; MultiplayerScreen* m_multiplayer = nullptr; + ChallengeManager* m_challengeManager = nullptr; // Making this into a separate class would be better, but it will (obviously) take a lot of work. // If you who are reading this comment have a lot of free time, feel free to refactor Game_Impl. :) @@ -182,6 +184,8 @@ class Game_Impl : public Game MapTime m_exitTriggerTime = 0; bool m_exitTriggerTimeSet = false; + HitWindow m_hitWindow = HitWindow::NORMAL; + public: Game_Impl(const String& mapPath, PlayOptions&& options) : m_playOptions(std::move(options)) { @@ -252,6 +256,11 @@ class Game_Impl : public Game { ProfilerScope $("AsyncLoad Game"); + // If CMod is not allowed, switch to MMod + if (IsChallenge() && m_speedMod == SpeedMods::CMod + && !m_challengeManager->GetCurrentOptions().allow_cmod.Get(true)) + m_speedMod = SpeedMods::MMod; + if(!Path::FileExists(m_chartPath)) { Logf("Couldn't find chart at %s", Logger::Severity::Error, m_chartPath); @@ -276,6 +285,32 @@ class Game_Impl : public Game m_endTime = m_beatmap->GetLastObjectTime(); m_gaugeSampleRate = m_endTime / 256; + if (IsMultiplayerGame()) + m_hitWindow = HitWindow::NORMAL; + else if (IsChallenge()) + { + m_hitWindow = HitWindow( + m_challengeManager->GetCurrentOptions().crit_judge.Get( + g_gameConfig.GetInt(GameConfigKeys::HitWindowPerfect) + ), + m_challengeManager->GetCurrentOptions().near_judge.Get( + g_gameConfig.GetInt(GameConfigKeys::HitWindowGood) + ), + m_challengeManager->GetCurrentOptions().hold_judge.Get( + g_gameConfig.GetInt(GameConfigKeys::HitWindowHold) + ) + ); + } + else + m_hitWindow = HitWindow::FromConfig(); + + // Double check that the window has not been widened by accident + if (!(m_hitWindow <= HitWindow::NORMAL)) + { + Log("HitWindow is automatically adjusted to NORMAL", Logger::Severity::Warning); + m_hitWindow = HitWindow::NORMAL; + } + const BeatmapSettings& mapSettings = m_beatmap->GetMapSettings(); // Move this somewhere else? @@ -314,10 +349,17 @@ class Game_Impl : public Game } m_hispeed = m_modSpeed / useBPM; + CheckChallengeHispeed(useBPM); } else if (m_speedMod == SpeedMods::CMod) { - m_hispeed = m_modSpeed / m_beatmap->GetLinearTimingPoints().front()->GetBPM(); + double bpm = m_beatmap->GetLinearTimingPoints().front()->GetBPM(); + m_hispeed = m_modSpeed / bpm; + CheckChallengeHispeed(bpm); + } + else if (m_speedMod == SpeedMods::XMod) + { + CheckChallengeHispeed(m_beatmap->GetLinearTimingPoints().front()->GetBPM()); } @@ -409,6 +451,17 @@ class Game_Impl : public Game m_track->suddenCutoff = 1.0f; m_track->hiddenCutoff = 0.0f; } + if (IsChallenge()) + { + float hiddenMin = m_challengeManager->GetCurrentOptions().hidden_min.Get(0.0); + if (m_track->hiddenCutoff < hiddenMin) + m_track->hiddenCutoff = hiddenMin; + + // Sudden is reversed since it starts at 1.0 + float suddenMin = 1.0 - m_challengeManager->GetCurrentOptions().sudden_min.Get(0.0); + if (m_track->suddenCutoff > suddenMin) + m_track->suddenCutoff = suddenMin; + } m_track->suddenFadewindow = g_gameConfig.GetFloat(GameConfigKeys::SuddenFade); m_track->hiddenFadewindow = g_gameConfig.GetFloat(GameConfigKeys::HiddenFade); @@ -545,6 +598,24 @@ class Game_Impl : public Game return true; } + void CheckChallengeHispeed(double bpm) + { + if (!IsChallenge()) + return; + // Get the current modspeed for the hispeed + m_modSpeed = m_hispeed * bpm; + // TODO should we optimize these accesses? + uint32 min = m_challengeManager->GetCurrentOptions().min_modspeed.Get(0); + uint32 max = m_challengeManager->GetCurrentOptions().max_modspeed.Get(INT_MAX); + if (m_modSpeed < min) + m_modSpeed = min; + else if (m_modSpeed > max) + m_modSpeed = max; + else + return; // No change needed + m_hispeed = m_modSpeed / bpm; + } + bool Init() override { return true; @@ -705,8 +776,14 @@ class Game_Impl : public Game g_gameConfig.Set(GameConfigKeys::ModSpeed, m_hispeed * (float)m_currentTiming->GetBPM()); } m_modSpeed = m_hispeed * (float)m_currentTiming->GetBPM(); + // Have to check in here so we can update m_playback + CheckChallengeHispeed(m_currentTiming->GetBPM()); m_playback.cModSpeed = m_modSpeed; } + else + { + CheckChallengeHispeed(m_currentTiming->GetBPM()); + } } } @@ -1171,7 +1248,12 @@ class Game_Impl : public Game { m_playback.OnTimingPointChanged.Add(this, &Game_Impl::OnTimingPointChanged); } + else if (IsChallenge()) + { + m_playback.OnTimingPointChanged.Add(this, &Game_Impl::OnTimingPointChangedChallenge); + } m_playback.cMod = m_speedMod == SpeedMods::CMod; + CheckChallengeHispeed(m_playback.GetCurrentTimingPoint().GetBPM()); m_playback.cModSpeed = m_hispeed * m_playback.GetCurrentTimingPoint().GetBPM(); // Register input bindings @@ -1521,6 +1603,9 @@ class Game_Impl : public Game if (m_multiplayer) m_multiplayer->SendFinalScore(this, m_getClearState()); + if (m_challengeManager) + m_challengeManager->ReportScore(this, m_getClearState()); + m_scoring.FinishGame(); m_ended = true; } @@ -1957,6 +2042,10 @@ class Game_Impl : public Game { m_hispeed = m_modSpeed / tp->GetBPM(); } + void OnTimingPointChangedChallenge(TimingPoint* tp) + { + CheckChallengeHispeed(tp->GetBPM()); + } void OnLaneToggleChanged(LaneHideTogglePoint* tp) { @@ -2073,7 +2162,7 @@ class Game_Impl : public Game m_manualExit = true; FinishGame(); } - else if(code == SDL_SCANCODE_PAUSE && m_multiplayer == nullptr) + else if(code == SDL_SCANCODE_PAUSE && !IsMultiplayerGame() && !IsChallenge()) { m_audioPlayback.TogglePause(); m_paused = m_audioPlayback.IsPaused(); @@ -2083,11 +2172,11 @@ class Game_Impl : public Game if(!SkipIntro() && !m_isPracticeSetup) SkipOutro(); } - else if(code == SDL_SCANCODE_PAGEUP && m_multiplayer == nullptr) + else if(code == SDL_SCANCODE_PAGEUP && !IsMultiplayerGame() && IsChallenge()) { m_audioPlayback.Advance(5000); } - else if(code == SDL_SCANCODE_F5 && m_multiplayer == nullptr && !m_isPracticeSetup) + else if(code == SDL_SCANCODE_F5 && !IsMultiplayerGame() && !IsChallenge() && !m_isPracticeSetup) { AbortMethod abortMethod = g_gameConfig.GetEnum(GameConfigKeys::RestartPlayMethod); if (abortMethod == AbortMethod::Press) @@ -2221,10 +2310,10 @@ class Game_Impl : public Game scoreData.almost = m_scoring.categorizedHits[1]; scoreData.crit = m_scoring.categorizedHits[2]; scoreData.gameflags = (uint32) GetFlags(); -scoreData.gauge = m_scoring.currentGauge; -scoreData.score = m_scoring.CalculateCurrentScore(); + scoreData.gauge = m_scoring.currentGauge; + scoreData.score = m_scoring.CalculateCurrentScore(); -return Scoring::CalculateBadge(scoreData); + return Scoring::CalculateBadge(scoreData); } void m_setLuaHolds(lua_State* L) @@ -2297,6 +2386,11 @@ return Scoring::CalculateBadge(scoreData); m_multiplayer = multiplayer; } + void MakeChallenge(ChallengeManager* manager) + { + m_challengeManager = manager; + } + // Called only for initialization of practice setup // (Do not use this for re-entering the setup scene) void MakePracticeSetup() @@ -2518,7 +2612,7 @@ return Scoring::CalculateBadge(scoreData); inline HitWindow GetHitWindow() const { - return IsMultiplayerGame() ? HitWindow::NORMAL : HitWindow::FromConfig(); + return m_hitWindow; } virtual bool IsPlaying() const override @@ -2616,6 +2710,10 @@ return Scoring::CalculateBadge(scoreData); { return m_multiplayer != nullptr; } + virtual bool IsChallenge() const + { + return m_challengeManager != nullptr; + } virtual ChartIndex* GetChartIndex() { return m_chartIndex; @@ -2916,6 +3014,13 @@ Game* Game::Create(MultiplayerScreen* multiplayer, ChartIndex* chart, PlayOption return impl; } +Game* Game::Create(ChallengeManager* challenge, ChartIndex* chart, PlayOptions&& options) +{ + Game_Impl* impl = new Game_Impl(chart, std::move(options)); + impl->MakeChallenge(challenge); + return impl; +} + Game* Game::Create(const String& mapPath, PlayOptions&& options) { Game_Impl* impl = new Game_Impl(mapPath, std::move(options)); diff --git a/Shared/include/Shared/Files.hpp b/Shared/include/Shared/Files.hpp index 330e35444..18c9f6d33 100644 --- a/Shared/include/Shared/Files.hpp +++ b/Shared/include/Shared/Files.hpp @@ -1,6 +1,8 @@ #pragma once #include "Shared/String.hpp" #include "Shared/Vector.hpp" +#include "Vector.hpp" +#include "Map.hpp" enum class FileType { @@ -25,13 +27,23 @@ struct FileInfo class Files { public: + // Finds files in a given folder + // uses the given extension filters if specified (results will be returned in a map with given exts as keys) + // Additional interruptible flag can contain a boolean which can interrupt the search when set to true + static Map> ScanFiles(const String& folder, const Vector& extFilters, bool* interrupt = nullptr); + + // Finds files in a given folder, recursively + // uses the given extension filters if specified (results will be returned in a map with given exts as keys) + // Additional interruptible flag can contain a boolean which can interrupt the search when set to true + static Map> ScanFilesRecursive(const String& folder, const Vector& extFilters, bool* interrupt = nullptr); + // Finds files in a given folder // uses the given extension filter if specified // Additional interruptible flag can contain a boolean which can interrupt the search when set to true - static Vector ScanFiles(const String& folder, String extFilter = String(), bool* interrupt = nullptr); + static Vector ScanFiles(const String& folder, const String& extFilter = String(), bool* interrupt = nullptr); // Finds files in a given folder, recursively // uses the given extension filter if specified // Additional interruptible flag can contain a boolean which can interrupt the search when set to true - static Vector ScanFilesRecursive(const String& folder, String extFilter = String(), bool* interrupt = nullptr); + static Vector ScanFilesRecursive(const String& folder, const String& extFilter = String(), bool* interrupt = nullptr); }; \ No newline at end of file diff --git a/Shared/src/Unix/Files.cpp b/Shared/src/Unix/Files.cpp index 85bd8e902..a712d7973 100644 --- a/Shared/src/Unix/Files.cpp +++ b/Shared/src/Unix/Files.cpp @@ -8,9 +8,24 @@ #include #include -static Vector _ScanFiles(String rootFolder, String extFilter, bool recurse, bool* interrupt) +static Map> _ScanFiles(const String& rootFolder, const Vector& extFilters, bool recurse, bool* interrupt) { - Vector ret; + // Found files will go in here. If there is no filter extensions or only "" then all files will have "" as their key + Map> ret; + + Vector fixedExts; + for (int i=0; i(); + + ext.TrimFront('.'); + fixedExts.push_back(ext); // Remove possible leading dot + } + if(!Path::IsDirectory(rootFolder)) { Logf("Can't run ScanFiles, \"%s\" is not a folder", Logger::Severity::Warning, rootFolder); @@ -23,9 +38,12 @@ static Vector _ScanFiles(String rootFolder, String extFilter, bool rec // Add / to the end folderQueue.AddBack(rootFolder); - bool filterByExtension = !extFilter.empty(); - extFilter.TrimFront('.'); // Remove possible leading dot - // Recursive folder search + // Either if we have no exts or no exts besides an empty string + bool filterByExtension = extFilters.size() != 0 && !(extFilters.size() == 1 && fixedExts[0].empty()); + // Make sure the empty one is ready + if (!filterByExtension) + ret[""] = Vector(); + while(!folderQueue.empty() && (!interrupt || !*interrupt)) { String searchPath = folderQueue.front(); @@ -65,7 +83,7 @@ static Vector _ScanFiles(String rootFolder, String extFilter, bool rec else if(!filterByExtension) { info.type = FileType::Folder; - ret.Add(info); + ret[""].push_back(info); } } else @@ -74,14 +92,20 @@ static Vector _ScanFiles(String rootFolder, String extFilter, bool rec if(filterByExtension) { String ext = Path::GetExtension(info.fullPath); - if(ext == extFilter) + for (int i = 0; i < extFilters.size(); i++) { - ret.Add(info); + const String& extFilter = fixedExts[i]; + if (ext == extFilter) + { + const String& realExt = extFilters[i]; + ret[realExt].push_back(info); + break; + } } } else { - ret.Add(info); + ret[""].push_back(info); } } } while((ent = readdir(dir)) && (!interrupt || !*interrupt)); @@ -93,11 +117,20 @@ static Vector _ScanFiles(String rootFolder, String extFilter, bool rec return move(ret); } +Map>Files::ScanFiles(const String& folder, const Vector& extFilters, bool* interrupt) +{ + return _ScanFiles(folder, extFilters, false, interrupt); +} +Map>Files::ScanFilesRecursive(const String& folder, const Vector& extFilters, bool* interrupt) +{ + return _ScanFiles(folder, extFilters, true, interrupt); +} + Vector Files::ScanFiles(const String& folder, String extFilter, bool* interrupt) { - return _ScanFiles(folder, extFilter, false, interrupt); + return _ScanFiles(folder, Vector(1, extFilter), false, interrupt)[extFilter]; } Vector Files::ScanFilesRecursive(const String& folder, String extFilter, bool* interrupt) { - return _ScanFiles(folder, extFilter, true, interrupt); + return _ScanFiles(folder, Vector(1, extFilter), true, interrupt)[extFilter]; } diff --git a/Shared/src/Windows/Files.cpp b/Shared/src/Windows/Files.cpp index 87c0598c6..f63199e17 100644 --- a/Shared/src/Windows/Files.cpp +++ b/Shared/src/Windows/Files.cpp @@ -4,9 +4,24 @@ #include "Log.hpp" #include "List.hpp" -static Vector _ScanFiles(const String& rootFolder, String extFilter, bool recurse, bool* interrupt) +static Map> _ScanFiles(const String& rootFolder, const Vector& extFilters, bool recurse, bool* interrupt) { - Vector ret; + // Found files will go in here. If there is no filter extensions or only "" then all files will have "" as their key + Map> ret; + + Vector fixedExts; + for (int i=0; i(); + + ext.TrimFront('.'); + fixedExts.push_back(ext); // Remove possible leading dot + } + if(!Path::IsDirectory(rootFolder)) { Logf("Can't run ScanFiles, \"%s\" is not a folder", Logger::Severity::Warning, rootFolder); @@ -17,8 +32,11 @@ static Vector _ScanFiles(const String& rootFolder, String extFilter, b List folderQueue; folderQueue.AddBack(rootFolder); - bool filterByExtension = !extFilter.empty(); - extFilter.TrimFront('.'); // Remove possible leading dot + // Either if we have no exts or no exts besides an empty string + bool filterByExtension = extFilters.size() != 0 && !(extFilters.size() == 1 && fixedExts[0].empty()); + // Make sure the empty one is ready + if (!filterByExtension) + ret[""] = Vector(); // Recursive folder search while(!folderQueue.empty() && (!interrupt || !*interrupt)) @@ -57,7 +75,7 @@ static Vector _ScanFiles(const String& rootFolder, String extFilter, b else if(!filterByExtension) { info.type = FileType::Folder; - ret.Add(info); + ret[""].push_back(info); } } else @@ -66,14 +84,20 @@ static Vector _ScanFiles(const String& rootFolder, String extFilter, b if(filterByExtension) { String ext = Path::GetExtension(filename); - if(ext == extFilter) + for (int i = 0; i < extFilters.size(); i++) { - ret.Add(info); + const String& extFilter = fixedExts[i]; + if (ext == extFilter) + { + const String& realExt = extFilters[i]; + ret[realExt].push_back(info); + break; + } } } else { - ret.Add(info); + ret[""].push_back(info); } } } while(FindNextFile(searchHandle, &findDataW) && (!interrupt || !*interrupt)); @@ -84,11 +108,21 @@ static Vector _ScanFiles(const String& rootFolder, String extFilter, b return move(ret); } -Vector Files::ScanFiles(const String& folder, String extFilter /*= String()*/, bool* interrupt) + +Map>Files::ScanFiles(const String& folder, const Vector& extFilters, bool* interrupt) +{ + return _ScanFiles(folder, extFilters, false, interrupt); +} +Map>Files::ScanFilesRecursive(const String& folder, const Vector& extFilters, bool* interrupt) +{ + return _ScanFiles(folder, extFilters, true, interrupt); +} + +Vector Files::ScanFiles(const String& folder, const String& extFilter /*= String()*/, bool* interrupt) { - return _ScanFiles(folder, extFilter, false, interrupt); + return _ScanFiles(folder, Vector(1, extFilter), false, interrupt)[extFilter]; } -Vector Files::ScanFilesRecursive(const String& folder, String extFilter /*= String()*/, bool* interrupt) +Vector Files::ScanFilesRecursive(const String& folder, const String& extFilter /*= String()*/, bool* interrupt) { - return _ScanFiles(folder, extFilter, true, interrupt); + return _ScanFiles(folder, Vector(1, extFilter), true, interrupt)[extFilter]; }