package funkin.play.scoring; import funkin.save.Save.SaveScoreData; /** * Which system to use when scoring and judging notes. */ enum abstract ScoringSystem(String) { /** * The scoring system used in versions of the game Week 6 and older. * Scores the player based on judgement, represented by a step function. */ var LEGACY; /** * The scoring system used in Week 7. It has tighter scoring windows than Legacy. * Scores the player based on judgement, represented by a step function. */ var WEEK7; /** * Points Based On Timing scoring system, version 1 * Scores the player based on the offset based on timing, represented by a sigmoid function. */ var PBOT1; } /** * A static class which holds any functions related to scoring. */ class Scoring { /** * Determine the score a note receives under a given scoring system. * @param msTiming The difference between the note's time and when it was hit. * @param scoringSystem The scoring system to use. * @return The score the note receives. */ public static function scoreNote(msTiming:Float, scoringSystem:ScoringSystem = PBOT1):Int { return switch (scoringSystem) { case LEGACY: scoreNoteLEGACY(msTiming); case WEEK7: scoreNoteWEEK7(msTiming); case PBOT1: scoreNotePBOT1(msTiming); default: FlxG.log.error('Unknown scoring system: ${scoringSystem}'); 0; } } /** * Determine the judgement a note receives under a given scoring system. * @param msTiming The difference between the note's time and when it was hit. * @param scoringSystem The scoring system to use. * @return The judgement the note receives. */ public static function judgeNote(msTiming:Float, scoringSystem:ScoringSystem = PBOT1):String { return switch (scoringSystem) { case LEGACY: judgeNoteLEGACY(msTiming); case WEEK7: judgeNoteWEEK7(msTiming); case PBOT1: judgeNotePBOT1(msTiming); default: FlxG.log.error('Unknown scoring system: ${scoringSystem}'); 'miss'; } } /** * The maximum score a note can receive. */ public static final PBOT1_MAX_SCORE:Int = 500; /** * The offset of the sigmoid curve for the scoring function. */ public static final PBOT1_SCORING_OFFSET:Float = 54.99; /** * The slope of the sigmoid curve for the scoring function. */ public static final PBOT1_SCORING_SLOPE:Float = 0.080; /** * The minimum score a note can receive while still being considered a hit. */ public static final PBOT1_MIN_SCORE:Float = 9.0; /** * The score a note receives when it is missed. */ public static final PBOT1_MISS_SCORE:Int = 0; /** * The threshold at which a note hit is considered perfect and always given the max score. */ public static final PBOT1_PERFECT_THRESHOLD:Float = 5.0; // 5ms /** * The threshold at which a note hit is considered missed. * `160ms` */ public static final PBOT1_MISS_THRESHOLD:Float = 160.0; /** * The time within which a note is considered to have been hit with the Killer judgement. * `~7.5% of the hit window, or 12.5ms` */ public static final PBOT1_KILLER_THRESHOLD:Float = 12.5; /** * The time within which a note is considered to have been hit with the Sick judgement. * `~25% of the hit window, or 45ms` */ public static final PBOT1_SICK_THRESHOLD:Float = 45.0; /** * The time within which a note is considered to have been hit with the Good judgement. * `~55% of the hit window, or 90ms` */ public static final PBOT1_GOOD_THRESHOLD:Float = 90.0; /** * The time within which a note is considered to have been hit with the Bad judgement. * `~85% of the hit window, or 135ms` */ public static final PBOT1_BAD_THRESHOLD:Float = 135.0; /** * The time within which a note is considered to have been hit with the Shit judgement. * `100% of the hit window, or 160ms` */ public static final PBOT1_SHIT_THRESHOLD:Float = 160.0; static function scoreNotePBOT1(msTiming:Float):Int { // Absolute value because otherwise late hits are always given the max score. var absTiming:Float = Math.abs(msTiming); return switch (absTiming) { case(_ > PBOT1_MISS_THRESHOLD) => true: PBOT1_MISS_SCORE; case(_ < PBOT1_PERFECT_THRESHOLD) => true: PBOT1_MAX_SCORE; default: // Fancy equation. var factor:Float = 1.0 - (1.0 / (1.0 + Math.exp(-PBOT1_SCORING_SLOPE * (absTiming - PBOT1_SCORING_OFFSET)))); var score:Int = Std.int(PBOT1_MAX_SCORE * factor + PBOT1_MIN_SCORE); score; } } static function judgeNotePBOT1(msTiming:Float):String { var absTiming:Float = Math.abs(msTiming); return switch (absTiming) { // case(_ < PBOT1_KILLER_THRESHOLD) => true: // 'killer'; case(_ < PBOT1_SICK_THRESHOLD) => true: 'sick'; case(_ < PBOT1_GOOD_THRESHOLD) => true: 'good'; case(_ < PBOT1_BAD_THRESHOLD) => true: 'bad'; case(_ < PBOT1_SHIT_THRESHOLD) => true: 'shit'; default: FlxG.log.warn('Missed note: Bad timing ($absTiming < $PBOT1_SHIT_THRESHOLD)'); 'miss'; } } /** * The window of time in which a note is considered to be hit, on the Funkin Legacy scoring system. * Currently equal to 10 frames at 60fps, or ~166ms. */ public static final LEGACY_HIT_WINDOW:Float = (10 / 60) * 1000; // 166.67 ms hit window (10 frames at 60fps) /** * The threshold at which a note is considered a "Sick" hit rather than another judgement. * Represented as a percentage of the total hit window. */ public static final LEGACY_SICK_THRESHOLD:Float = 0.2; /** * The threshold at which a note is considered a "Good" hit rather than another judgement. * Represented as a percentage of the total hit window. */ public static final LEGACY_GOOD_THRESHOLD:Float = 0.75; /** * The threshold at which a note is considered a "Bad" hit rather than another judgement. * Represented as a percentage of the total hit window. */ public static final LEGACY_BAD_THRESHOLD:Float = 0.9; /** * The score a note receives when hit within the Shit threshold, rather than a miss. * Represented as a percentage of the total hit window. */ public static final LEGACY_SHIT_THRESHOLD:Float = 1.0; /** * The score a note receives when hit within the Sick threshold. */ public static final LEGACY_SICK_SCORE:Int = 350; /** * The score a note receives when hit within the Good threshold. */ public static final LEGACY_GOOD_SCORE:Int = 200; /** * The score a note receives when hit within the Bad threshold. */ public static final LEGACY_BAD_SCORE:Int = 100; /** * The score a note receives when hit within the Shit threshold. */ public static final LEGACY_SHIT_SCORE:Int = 50; static function scoreNoteLEGACY(msTiming:Float):Int { var absTiming:Float = Math.abs(msTiming); return switch (absTiming) { case(_ < LEGACY_HIT_WINDOW * LEGACY_SICK_THRESHOLD) => true: LEGACY_SICK_SCORE; case(_ < LEGACY_HIT_WINDOW * LEGACY_GOOD_THRESHOLD) => true: LEGACY_GOOD_SCORE; case(_ < LEGACY_HIT_WINDOW * LEGACY_BAD_THRESHOLD) => true: LEGACY_BAD_SCORE; case(_ < LEGACY_HIT_WINDOW * LEGACY_SHIT_THRESHOLD) => true: LEGACY_SHIT_SCORE; default: 0; } } static function judgeNoteLEGACY(msTiming:Float):String { var absTiming:Float = Math.abs(msTiming); return switch (absTiming) { case(_ < LEGACY_HIT_WINDOW * LEGACY_SICK_THRESHOLD) => true: 'sick'; case(_ < LEGACY_HIT_WINDOW * LEGACY_GOOD_THRESHOLD) => true: 'good'; case(_ < LEGACY_HIT_WINDOW * LEGACY_BAD_THRESHOLD) => true: 'bad'; case(_ < LEGACY_HIT_WINDOW * LEGACY_SHIT_THRESHOLD) => true: 'shit'; default: FlxG.log.warn('Missed note: Bad timing ($absTiming < $LEGACY_SHIT_THRESHOLD)'); 'miss'; } } /** * The window of time in which a note is considered to be hit, on the Funkin Classic scoring system. * Same as L 10 frames at 60fps, or ~166ms. */ public static final WEEK7_HIT_WINDOW:Float = LEGACY_HIT_WINDOW; public static final WEEK7_BAD_THRESHOLD:Float = 0.8; // 80% of the hit window, or ~125ms public static final WEEK7_GOOD_THRESHOLD:Float = 0.55; // 55% of the hit window, or ~91ms public static final WEEK7_SICK_THRESHOLD:Float = 0.2; // 20% of the hit window, or ~33ms public static final WEEK7_SHIT_SCORE:Int = 50; public static final WEEK7_BAD_SCORE:Int = 100; public static final WEEK7_GOOD_SCORE:Int = 200; public static final WEEK7_SICK_SCORE:Int = 350; static function scoreNoteWEEK7(msTiming:Float):Int { var absTiming:Float = Math.abs(msTiming); return switch (absTiming) { case(_ < WEEK7_HIT_WINDOW * WEEK7_SICK_THRESHOLD) => true: LEGACY_SICK_SCORE; case(_ < WEEK7_HIT_WINDOW * WEEK7_GOOD_THRESHOLD) => true: LEGACY_GOOD_SCORE; case(_ < WEEK7_HIT_WINDOW * WEEK7_BAD_THRESHOLD) => true: LEGACY_BAD_SCORE; case(_ < WEEK7_HIT_WINDOW) => true: LEGACY_SHIT_SCORE; default: 0; } if (absTiming < WEEK7_HIT_WINDOW * WEEK7_SICK_THRESHOLD) { return WEEK7_SICK_SCORE; } else if (absTiming < WEEK7_HIT_WINDOW * WEEK7_GOOD_THRESHOLD) { return WEEK7_GOOD_SCORE; } else if (absTiming < WEEK7_HIT_WINDOW * WEEK7_BAD_THRESHOLD) { return WEEK7_BAD_SCORE; } else if (absTiming < WEEK7_HIT_WINDOW) { return WEEK7_SHIT_SCORE; } else { return 0; } } static function judgeNoteWEEK7(msTiming:Float):String { var absTiming = Math.abs(msTiming); if (absTiming < WEEK7_HIT_WINDOW * WEEK7_SICK_THRESHOLD) { return 'sick'; } else if (absTiming < WEEK7_HIT_WINDOW * WEEK7_GOOD_THRESHOLD) { return 'good'; } else if (absTiming < WEEK7_HIT_WINDOW * WEEK7_BAD_THRESHOLD) { return 'bad'; } else if (absTiming < WEEK7_HIT_WINDOW) { return 'shit'; } else { FlxG.log.warn('Missed note: Bad timing ($absTiming < $WEEK7_HIT_WINDOW)'); return 'miss'; } } public static function calculateRank(scoreData:Null):Null { if (scoreData?.tallies.totalNotes == 0 || scoreData == null) return null; // we can return null here, meaning that the player hasn't actually played and finished the song (thus has no data) if (scoreData.tallies.totalNotes == 0) return null; // Perfect (Platinum) is a Sick Full Clear var isPerfectGold = scoreData.tallies.sick == scoreData.tallies.totalNotes; if (isPerfectGold) { return ScoringRank.PERFECT_GOLD; } // Else, use the standard grades // Grade % (only good and sick), 1.00 is a full combo var grade = (scoreData.tallies.sick + scoreData.tallies.good) / scoreData.tallies.totalNotes; // Clear % (including bad and shit). 1.00 is a full clear but not a full combo var clear = (scoreData.tallies.totalNotesHit) / scoreData.tallies.totalNotes; if (grade == Constants.RANK_PERFECT_THRESHOLD) { return ScoringRank.PERFECT; } else if (grade >= Constants.RANK_EXCELLENT_THRESHOLD) { return ScoringRank.EXCELLENT; } else if (grade >= Constants.RANK_GREAT_THRESHOLD) { return ScoringRank.GREAT; } else if (grade >= Constants.RANK_GOOD_THRESHOLD) { return ScoringRank.GOOD; } else { return ScoringRank.SHIT; } } } enum abstract ScoringRank(String) { var PERFECT_GOLD; var PERFECT; var EXCELLENT; var GREAT; var GOOD; var SHIT; /** * 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 = getValue(a); var temp2:Int = getValue(b); 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 */ public function getMusicDelay():Float { switch (abstract) { case PERFECT_GOLD | PERFECT: // return 2.5; return 95 / 24; case EXCELLENT: return 0; case GREAT: return 5 / 24; case GOOD: return 3 / 24; case SHIT: return 2 / 24; default: return 3.5; } } public function getBFDelay():Float { switch (abstract) { case PERFECT_GOLD | PERFECT: // return 2.5; return 95 / 24; case EXCELLENT: return 97 / 24; case GREAT: return 95 / 24; case GOOD: return 95 / 24; case SHIT: return 95 / 24; default: return 3.5; } } public function getFlashDelay():Float { switch (abstract) { case PERFECT_GOLD | PERFECT: // return 2.5; return 129 / 24; case EXCELLENT: return 122 / 24; case GREAT: return 109 / 24; case GOOD: return 107 / 24; case SHIT: return 186 / 24; default: return 3.5; } } public function getHighscoreDelay():Float { switch (abstract) { case PERFECT_GOLD | PERFECT: // return 2.5; return 140 / 24; case EXCELLENT: return 140 / 24; case GREAT: return 129 / 24; case GOOD: return 127 / 24; case SHIT: return 207 / 24; default: return 3.5; } } public function getMusicPath():String { switch (abstract) { case PERFECT_GOLD: return 'resultsPERFECT'; case PERFECT: return 'resultsPERFECT'; case EXCELLENT: return 'resultsEXCELLENT'; case GREAT: return 'resultsNORMAL'; case GOOD: return 'resultsNORMAL'; case SHIT: return 'resultsSHIT'; default: return 'resultsNORMAL'; } } public function hasMusicIntro():Bool { switch (abstract) { case EXCELLENT: return true; case SHIT: return true; default: return false; } } public function getFreeplayRankIconAsset():Null { switch (abstract) { case PERFECT_GOLD: return 'PERFECTSICK'; case PERFECT: return 'PERFECT'; case EXCELLENT: return 'EXCELLENT'; case GREAT: return 'GREAT'; case GOOD: return 'GOOD'; case SHIT: return 'LOSS'; default: return null; } } public function shouldMusicLoop():Bool { switch (abstract) { case PERFECT_GOLD | PERFECT | EXCELLENT | GREAT | GOOD: return true; case SHIT: return false; default: return false; } } public function getHorTextAsset() { switch (abstract) { case PERFECT_GOLD: return 'resultScreen/rankText/rankScrollPERFECT'; case PERFECT: return 'resultScreen/rankText/rankScrollPERFECT'; case EXCELLENT: return 'resultScreen/rankText/rankScrollEXCELLENT'; case GREAT: return 'resultScreen/rankText/rankScrollGREAT'; case GOOD: return 'resultScreen/rankText/rankScrollGOOD'; case SHIT: return 'resultScreen/rankText/rankScrollLOSS'; default: return 'resultScreen/rankText/rankScrollGOOD'; } } public function getVerTextAsset() { switch (abstract) { case PERFECT_GOLD: return 'resultScreen/rankText/rankTextPERFECT'; case PERFECT: return 'resultScreen/rankText/rankTextPERFECT'; case EXCELLENT: return 'resultScreen/rankText/rankTextEXCELLENT'; case GREAT: return 'resultScreen/rankText/rankTextGREAT'; case GOOD: return 'resultScreen/rankText/rankTextGOOD'; case SHIT: return 'resultScreen/rankText/rankTextLOSS'; default: return 'resultScreen/rankText/rankTextGOOD'; } } }