diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index b3d0a9f8a..ad7a398d4 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -2284,7 +2284,7 @@ class PlayState extends MusicBeatSubState health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed; songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed); } - + // Make sure the player keeps singing while the note is held by the bot. if (isBotPlayMode && currentStage != null && currentStage.getBoyfriend() != null && currentStage.getBoyfriend().isSinging()) { @@ -2818,8 +2818,13 @@ class PlayState extends MusicBeatSubState deathCounter = 0; + // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration. + // `easy`, `erect`, `normal-pico`, etc. + var suffixedDifficulty = (currentVariation != Constants.DEFAULT_VARIATION + && currentVariation != 'erect') ? '$currentDifficulty-${currentVariation}' : currentDifficulty; + var isNewHighscore = false; - var prevScoreData:Null = Save.instance.getSongScore(currentSong.id, currentDifficulty); + var prevScoreData:Null = Save.instance.getSongScore(currentSong.id, suffixedDifficulty); if (currentSong != null && currentSong.validScore) { @@ -2844,13 +2849,21 @@ class PlayState extends MusicBeatSubState // adds current song data into the tallies for the level (story levels) Highscore.talliesLevel = Highscore.combineTallies(Highscore.tallies, Highscore.talliesLevel); - if (!isPracticeMode && !isBotPlayMode && Save.instance.isSongHighScore(currentSong.id, currentDifficulty, data)) + if (!isPracticeMode && !isBotPlayMode) { - Save.instance.setSongScore(currentSong.id, currentDifficulty, data); - #if newgrounds - NGio.postScore(score, currentSong.id); - #end - isNewHighscore = true; + isNewHighscore = Save.instance.isSongHighScore(currentSong.id, suffixedDifficulty, data); + + // If no high score is present, save both score and rank. + // If score or rank are better, save the highest one. + // If neither are higher, nothing will change. + Save.instance.applySongRank(currentSong.id, suffixedDifficulty, data); + + if (isNewHighscore) + { + #if newgrounds + NGio.postScore(score, currentSong.id); + #end + } } } diff --git a/source/funkin/play/scoring/Scoring.hx b/source/funkin/play/scoring/Scoring.hx index d6f71fc7e..dc2c40647 100644 --- a/source/funkin/play/scoring/Scoring.hx +++ b/source/funkin/play/scoring/Scoring.hx @@ -356,7 +356,10 @@ class Scoring // Perfect (Platinum) is a Sick Full Clear var isPerfectGold = scoreData.tallies.sick == scoreData.tallies.totalNotes; - if (isPerfectGold) return ScoringRank.PERFECT_GOLD; + if (isPerfectGold) + { + return ScoringRank.PERFECT_GOLD; + } // Else, use the standard grades @@ -397,62 +400,79 @@ enum abstract ScoringRank(String) var GOOD; var SHIT; - @:op(A > B) static function compare(a:Null, b:Null):Bool + /** + * Converts ScoringRank to an integer value for comparison. + * Better ranks should be tied to a higher value. + */ + static function getValue(rank:Null):Int + { + if (rank == null) return -1; + switch (rank) + { + case PERFECT_GOLD: + return 5; + case PERFECT: + return 4; + case EXCELLENT: + return 3; + case GREAT: + return 2; + case GOOD: + return 1; + case SHIT: + return 0; + default: + return -1; + } + } + + // Yes, we really need a different function for each comparison operator. + @:op(A > B) static function compareGT(a:Null, b:Null):Bool { if (a != null && b == null) return true; if (a == null || b == null) return false; - var temp1:Int = 0; - var temp2:Int = 0; + var temp1:Int = getValue(a); + var temp2:Int = getValue(b); - // temp 1 - switch (a) - { - case PERFECT_GOLD: - temp1 = 5; - case PERFECT: - temp1 = 4; - case EXCELLENT: - temp1 = 3; - case GREAT: - temp1 = 2; - case GOOD: - temp1 = 1; - case SHIT: - temp1 = 0; - default: - temp1 = -1; - } - - // temp 2 - switch (b) - { - case PERFECT_GOLD: - temp2 = 5; - case PERFECT: - temp2 = 4; - case EXCELLENT: - temp2 = 3; - case GREAT: - temp2 = 2; - case GOOD: - temp2 = 1; - case SHIT: - temp2 = 0; - default: - temp2 = -1; - } - - if (temp1 > temp2) - { - return true; - } - else - { - return false; - } + return temp1 > temp2; } + @:op(A >= B) static function compareGTEQ(a:Null, b:Null):Bool + { + if (a != null && b == null) return true; + if (a == null || b == null) return false; + + var temp1:Int = getValue(a); + var temp2:Int = getValue(b); + + return temp1 >= temp2; + } + + @:op(A < B) static function compareLT(a:Null, b:Null):Bool + { + if (a != null && b == null) return true; + if (a == null || b == null) return false; + + var temp1:Int = getValue(a); + var temp2:Int = getValue(b); + + return temp1 < temp2; + } + + @:op(A <= B) static function compareLTEQ(a:Null, b:Null):Bool + { + if (a != null && b == null) return true; + if (a == null || b == null) return false; + + var temp1:Int = getValue(a); + var temp2:Int = getValue(b); + + return temp1 <= temp2; + } + + // @:op(A == B) isn't necessary! + /** * Delay in seconds */ @@ -462,15 +482,15 @@ enum abstract ScoringRank(String) { case PERFECT_GOLD | PERFECT: // return 2.5; - return 95/24; + return 95 / 24; case EXCELLENT: return 0; case GREAT: - return 5/24; + return 5 / 24; case GOOD: - return 3/24; + return 3 / 24; case SHIT: - return 2/24; + return 2 / 24; default: return 3.5; } @@ -482,15 +502,15 @@ enum abstract ScoringRank(String) { case PERFECT_GOLD | PERFECT: // return 2.5; - return 95/24; + return 95 / 24; case EXCELLENT: - return 97/24; + return 97 / 24; case GREAT: - return 95/24; + return 95 / 24; case GOOD: - return 95/24; + return 95 / 24; case SHIT: - return 95/24; + return 95 / 24; default: return 3.5; } @@ -502,15 +522,15 @@ enum abstract ScoringRank(String) { case PERFECT_GOLD | PERFECT: // return 2.5; - return 129/24; + return 129 / 24; case EXCELLENT: - return 122/24; + return 122 / 24; case GREAT: - return 109/24; + return 109 / 24; case GOOD: - return 107/24; + return 107 / 24; case SHIT: - return 186/24; + return 186 / 24; default: return 3.5; } @@ -522,15 +542,15 @@ enum abstract ScoringRank(String) { case PERFECT_GOLD | PERFECT: // return 2.5; - return 140/24; + return 140 / 24; case EXCELLENT: - return 140/24; + return 140 / 24; case GREAT: - return 129/24; + return 129 / 24; case GOOD: - return 127/24; + return 127 / 24; case SHIT: - return 207/24; + return 207 / 24; default: return 3.5; } diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx index 2ff6b96cc..2900ce2be 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -1,6 +1,7 @@ package funkin.save; import flixel.util.FlxSave; +import funkin.util.FileUtil; import funkin.input.Controls.Device; import funkin.play.scoring.Scoring; import funkin.play.scoring.Scoring.ScoringRank; @@ -58,7 +59,7 @@ class Save this.data = data; // Make sure the verison number is up to date before we flush. - this.data.version = Save.SAVE_DATA_VERSION; + updateVersionToLatest(); } public static function getDefault():RawSaveData @@ -503,7 +504,7 @@ class Save } /** - * Apply the score the user achieved for a given song on a given difficulty. + * Directly set the score the user achieved for a given song on a given difficulty. */ public function setSongScore(songId:String, difficultyId:String, score:SaveScoreData):Void { @@ -518,6 +519,44 @@ class Save flush(); } + /** + * Only replace the ranking data for the song, because the old score is still better. + */ + public function applySongRank(songId:String, difficultyId:String, newScoreData:SaveScoreData):Void + { + var newRank = Scoring.calculateRank(newScoreData); + if (newScoreData == null || newRank == null) return; + + var song = data.scores.songs.get(songId); + if (song == null) + { + song = []; + data.scores.songs.set(songId, song); + } + + var previousScoreData = song.get(difficultyId); + + var previousRank = Scoring.calculateRank(previousScoreData); + + if (previousScoreData == null || previousRank == null) + { + // Directly set the highscore. + setSongScore(songId, difficultyId, newScoreData); + return; + } + + // Set the high score and the high rank separately. + var newScore:SaveScoreData = + { + score: (previousScoreData.score > newScoreData.score) ? previousScoreData.score : newScoreData.score, + tallies: (previousRank > newRank) ? previousScoreData.tallies : newScoreData.tallies + }; + + song.set(difficultyId, newScore); + + flush(); + } + /** * Is the provided score data better than the current high score for the given song? * @param songId The song ID to check. @@ -543,6 +582,39 @@ class Save return score.score > currentScore.score; } + /** + * Is the provided score data better than the current rank for the given song? + * @param songId The song ID to check. + * @param difficultyId The difficulty to check. + * @param score The score to check the rank for. + * @return Whether the score's rank is better than the current rank. + */ + public function isSongHighRank(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool + { + var newScoreRank = Scoring.calculateRank(score); + if (newScoreRank == null) + { + // The provided score is invalid. + return false; + } + + var song = data.scores.songs.get(songId); + if (song == null) + { + song = []; + data.scores.songs.set(songId, song); + } + var currentScore = song.get(difficultyId); + var currentScoreRank = Scoring.calculateRank(currentScore); + if (currentScoreRank == null) + { + // There is no primary highscore for this song. + return true; + } + + return newScoreRank > currentScoreRank; + } + /** * Has the provided song been beaten on one of the listed difficulties? * @param songId The song ID to check. @@ -832,6 +904,29 @@ class Save return cast legacySave.data; } } + + /** + * Serialize this Save into a JSON string. + * @param pretty Whether the JSON should be big ol string (false), + * or formatted with tabs (true) + * @return The JSON string. + */ + public function serialize(pretty:Bool = true):String + { + var ignoreNullOptionals = true; + var writer = new json2object.JsonWriter(ignoreNullOptionals); + return writer.write(data, pretty ? ' ' : null); + } + + public function updateVersionToLatest():Void + { + this.data.version = Save.SAVE_DATA_VERSION; + } + + public function debug_dumpSave():Void + { + FileUtil.saveFile(haxe.io.Bytes.ofString(this.serialize()), [FileUtil.FILE_FILTER_JSON], null, null, './save.json', 'Write save data as JSON...'); + } } /** @@ -904,6 +999,9 @@ typedef SaveHighScoresData = typedef SaveDataMods = { var enabledMods:Array; + + // TODO: Make this not trip up the serializer when debugging. + @:jignored var modOptions:Map; } diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index 05bb53d22..06684ae7c 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -1565,7 +1565,19 @@ class FreeplayState extends MusicBeatSubState var daSong:Null = grpCapsules.members[curSelected].songData; if (daSong != null) { - var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, currentDifficulty); + // TODO: Make this actually be the variation you're focused on. We don't need to fetch the song metadata just to calculate it. + var targetSong:Song = SongRegistry.instance.fetchEntry(grpCapsules.members[curSelected].songData.songId); + if (targetSong == null) + { + FlxG.log.warn('WARN: could not find song with id (${grpCapsules.members[curSelected].songData.songId})'); + return; + } + var targetVariation:String = targetSong.getFirstValidVariation(currentDifficulty); + + // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration. + var suffixedDifficulty = (targetVariation != Constants.DEFAULT_VARIATION + && targetVariation != 'erect') ? '$currentDifficulty-${targetVariation}' : currentDifficulty; + var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, suffixedDifficulty); intendedScore = songScore?.score ?? 0; intendedCompletion = songScore == null ? 0.0 : ((songScore.tallies.sick + songScore.tallies.good) / songScore.tallies.totalNotes); rememberedDifficulty = currentDifficulty; diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx index b6ec25e61..d09536eea 100644 --- a/source/funkin/ui/mainmenu/MainMenuState.hx +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -371,6 +371,33 @@ class MainMenuState extends MusicBeatState } }); } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.R) + { + // Give the user a hypothetical overridden score, + // and see if we can maintain that golden P rank. + funkin.save.Save.instance.setSongScore('tutorial', 'easy', + { + score: 1234567, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 1, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 1, + totalNotes: 10, + } + }); + } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.E) + { + funkin.save.Save.instance.debug_dumpSave(); + } #end if (FlxG.sound.music != null && FlxG.sound.music.volume < 0.8) diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx index 7a7b1422c..00a0a14b7 100644 --- a/source/funkin/util/FileUtil.hx +++ b/source/funkin/util/FileUtil.hx @@ -19,6 +19,7 @@ import haxe.ui.containers.dialogs.Dialogs.FileDialogExtensionInfo; class FileUtil { public static final FILE_FILTER_FNFC:FileFilter = new FileFilter("Friday Night Funkin' Chart (.fnfc)", "*.fnfc"); + public static final FILE_FILTER_JSON:FileFilter = new FileFilter("JSON Data File (.json)", "*.json"); public static final FILE_FILTER_ZIP:FileFilter = new FileFilter("ZIP Archive (.zip)", "*.zip"); public static final FILE_FILTER_PNG:FileFilter = new FileFilter("PNG Image (.png)", "*.png");