diff --git a/source/funkin/backend/scripting/events/note/NoteHitEvent.hx b/source/funkin/backend/scripting/events/note/NoteHitEvent.hx index c9f8c4c95..e104d6830 100644 --- a/source/funkin/backend/scripting/events/note/NoteHitEvent.hx +++ b/source/funkin/backend/scripting/events/note/NoteHitEvent.hx @@ -118,6 +118,10 @@ final class NoteHitEvent extends CancellableEvent { * The attached healthIcon used distinction for icons amongst others */ public var healthIcon:HealthIcon; + /** + * Whether note hits are judged in the old way or not. + */ + public var legacyJudge(get, set):Bool; /** * Prevents the default sing animation from being played. @@ -196,4 +200,15 @@ final class NoteHitEvent extends CancellableEvent { characters = [char]; return char; } + + private var _explicitLegacyJudge:Null = null; + private inline function get_legacyJudge():Bool { + if (_explicitLegacyJudge != null) + return _explicitLegacyJudge; + return Flags.CURRENT_API_VERSION == 1; + } + private function set_legacyJudge(value:Bool):Bool { + _explicitLegacyJudge = value; + return value; + } } diff --git a/source/funkin/backend/system/Flags.hx b/source/funkin/backend/system/Flags.hx index 1b96f7260..07b9a4532 100644 --- a/source/funkin/backend/system/Flags.hx +++ b/source/funkin/backend/system/Flags.hx @@ -41,7 +41,7 @@ class Flags { @:lazy public static var SAVE_PATH:String = haxe.macro.Compiler.getDefine("SAVE_PATH"); @:lazy public static var SAVE_NAME:String = haxe.macro.Compiler.getDefine("SAVE_NAME"); - public static var CURRENT_API_VERSION:Int = 1; + public static var CURRENT_API_VERSION:Int = 2; public static var COMMIT_NUMBER:Int = GitCommitMacro.commitNumber; public static var COMMIT_HASH:String = GitCommitMacro.commitHash; public static var COMMIT_MESSAGE:String = 'Commit $COMMIT_NUMBER ($COMMIT_HASH)'; diff --git a/source/funkin/game/Note.hx b/source/funkin/game/Note.hx index f41496a21..bea0e4da7 100644 --- a/source/funkin/game/Note.hx +++ b/source/funkin/game/Note.hx @@ -60,6 +60,15 @@ class Note extends FlxSprite */ public var sustainParent:Null; + /** + * Number of active sustain pieces attached to this note + * + * Increases by 1 every time a hold piece is initialized. + * + * Decreases by 1 every time a hold piece gets destroyed. + */ + public var tailCount:Int = 0; + /** * Name of the splash. */ @@ -104,6 +113,8 @@ class Note extends FlxSprite public var animSuffix:String = null; + public var tripTimer:Float = 0; // ranges from 0 to 1 + private static function customTypePathExists(path:String) { if (__customNoteTypeExists.exists(path)) diff --git a/source/funkin/game/PlayState.hx b/source/funkin/game/PlayState.hx index 76752ac43..32c06f9e5 100644 --- a/source/funkin/game/PlayState.hx +++ b/source/funkin/game/PlayState.hx @@ -29,6 +29,8 @@ import funkin.editors.charter.Charter; import funkin.editors.charter.CharterSelection; import funkin.game.SplashHandler; import funkin.game.cutscenes.*; +import funkin.game.scoring.*; +import funkin.game.scoring.RatingManager.Rating; import funkin.menus.*; import funkin.backend.week.WeekData; import funkin.savedata.FunkinSave; @@ -529,6 +531,10 @@ class PlayState extends MusicBeatState * Group containing all of the combo sprites. */ public var comboGroup:RotatingSpriteGroup; + /** + * Manager that helps judge note hits to return ratings. + */ + public var ratingManager:RatingManager = new RatingManager(); /** * Whenever the Rating sprites should be shown or not. * @@ -1880,40 +1886,44 @@ class PlayState extends MusicBeatState * CALCULATES RATING */ var noteDiff = Math.abs(Conductor.songPosition - note.strumTime); - var daRating:String = "sick"; - var score:Int = 300; - var accuracy:Float = 1; - - if (noteDiff > hitWindow * 0.9) - { - daRating = 'shit'; - score = 50; - accuracy = 0.25; - } - else if (noteDiff > hitWindow * 0.75) - { - daRating = 'bad'; - score = 100; - accuracy = 0.45; - } - else if (noteDiff > hitWindow * 0.2) - { - daRating = 'good'; - score = 200; - accuracy = 0.75; - } + var daRating:Rating = ratingManager.judgeNote(noteDiff); var event:NoteHitEvent; if (strumLine != null && !strumLine.cpu) - event = EventManager.get(NoteHitEvent).recycle(false, !note.isSustainNote, !note.isSustainNote, null, defaultDisplayRating, defaultDisplayCombo, note, strumLine.characters, true, note.noteType, note.animSuffix.getDefault(note.strumID < strumLine.members.length ? strumLine.members[note.strumID].animSuffix : strumLine.animSuffix), "game/score/", "", note.strumID, score, note.isSustainNote ? null : accuracy, 0.023, daRating, Options.splashesEnabled && !note.isSustainNote && daRating == "sick", 0.5, true, 0.7, true, true, iconP1); + event = EventManager.get(NoteHitEvent).recycle(false, !note.isSustainNote, !note.isSustainNote, null, defaultDisplayRating, defaultDisplayCombo, note, strumLine.characters, true, note.noteType, note.animSuffix.getDefault(note.strumID < strumLine.members.length ? strumLine.members[note.strumID].animSuffix : strumLine.animSuffix), "game/score/", "", note.strumID, daRating.score, note.isSustainNote ? null : daRating.accuracy, 0.023, daRating.name, Options.splashesEnabled && !note.isSustainNote && daRating.splash, 0.5, true, 0.7, true, true, iconP1); else - event = EventManager.get(NoteHitEvent).recycle(false, false, false, null, defaultDisplayRating, defaultDisplayCombo, note, strumLine.characters, false, note.noteType, note.animSuffix.getDefault(note.strumID < strumLine.members.length ? strumLine.members[note.strumID].animSuffix : strumLine.animSuffix), "game/score/", "", note.strumID, 0, null, 0, daRating, false, 0.5, true, 0.7, true, true, iconP2); + event = EventManager.get(NoteHitEvent).recycle(false, false, false, null, defaultDisplayRating, defaultDisplayCombo, note, strumLine.characters, false, note.noteType, note.animSuffix.getDefault(note.strumID < strumLine.members.length ? strumLine.members[note.strumID].animSuffix : strumLine.animSuffix), "game/score/", "", note.strumID, 0, null, 0, daRating.name, false, 0.5, true, 0.7, true, true, iconP2); event.deleteNote = !note.isSustainNote; // work around, to allow sustain notes to be deleted event = scripts.event(strumLine != null && !strumLine.cpu ? "onPlayerHit" : "onDadHit", event); strumLine.onHit.dispatch(event); gameAndCharsEvent("onNoteHit", event); if (!event.cancelled) { + if (event.legacyJudge) { + event.rating = 'sick'; + event.score = 300; + event.accuracy = 1; + + if (noteDiff > hitWindow * 0.9) + { + event.rating = 'shit'; + event.score = 50; + event.accuracy = 0.25; + } + else if (noteDiff > hitWindow * 0.75) + { + event.rating = 'bad'; + event.score = 100; + event.accuracy = 0.45; + } + else if (noteDiff > hitWindow * 0.2) + { + event.rating = 'good'; + event.score = 200; + event.accuracy = 0.75; + } + } + if (!note.isSustainNote) { if (event.countScore) songScore += event.score; if (event.accuracy != null) { diff --git a/source/funkin/game/StrumLine.hx b/source/funkin/game/StrumLine.hx index ae5303b8c..41acd96db 100644 --- a/source/funkin/game/StrumLine.hx +++ b/source/funkin/game/StrumLine.hx @@ -161,6 +161,9 @@ class StrumLine extends FlxTypedGroup { curLen = Math.min(len, Conductor.stepCrochet); notes.members[total-(il++)-1] = prev = new Note(this, note, true, curLen, note.sLen - len, prev); len -= curLen; + + if (prev != null && prev.sustainParent != null) + prev.sustainParent.tailCount++; } } } @@ -236,10 +239,23 @@ class StrumLine extends FlxTypedGroup { if (__updateNote_event.strum == null) return; - if (__updateNote_event.__reposNote) __updateNote_event.strum.updateNotePosition(daNote); + + if (daNote.isSustainNote) + { daNote.updateSustain(__updateNote_event.strum); + + if (daNote.tripTimer > 0 && daNote.tailCount > 3) + { + daNote.tripTimer -= 0.05 / daNote.sustainLength; + if (daNote.tripTimer <= 0) + { + daNote.tripTimer = 0; + daNote.canBeHit = false; + } + } + } } var __funcsToExec:ArrayVoid> = []; @@ -249,16 +265,27 @@ class StrumLine extends FlxTypedGroup { var __notePerStrum:Array = []; function __inputProcessPressed(note:Note) { - if (__pressed[note.strumID] && note.isSustainNote && note.strumTime < __updateNote_songPos && !note.wasGoodHit) { + if (__pressed[note.strumID] && note.isSustainNote && note.sustainParent != null && note.sustainParent.wasGoodHit && note.strumTime < __updateNote_songPos && !note.wasGoodHit) { + note.tripTimer = 1; PlayState.instance.goodNoteHit(this, note); note.updateSustainClip(); } } function __inputProcessJustPressed(note:Note) { if (__justPressed[note.strumID] && !note.isSustainNote && !note.wasGoodHit && note.canBeHit) { - if (__notePerStrum[note.strumID] == null) __notePerStrum[note.strumID] = note; - else if (Math.abs(__notePerStrum[note.strumID].strumTime - note.strumTime) <= 2) deleteNote(note); - else if (note.strumTime < __notePerStrum[note.strumID].strumTime) __notePerStrum[note.strumID] = note; + var cur = __notePerStrum[note.strumID]; + var songPos = __updateNote_songPos; + + var noteDist = Math.abs(note.strumTime - songPos); + var curDist = cur != null ? Math.abs(cur.strumTime - songPos) : 999999; + + var notePenalty = note.avoid ? 1 : 0; + var curPenalty = (cur != null && cur.avoid) ? 1 : 0; + + if (cur == null + || notePenalty < curPenalty + || (notePenalty == curPenalty && noteDist < curDist)) + __notePerStrum[note.strumID] = note; } } @@ -272,14 +299,14 @@ class StrumLine extends FlxTypedGroup { if (cpu) return; __funcsToExec.clear(); - __pressed.clear(); - __justPressed.clear(); - __justReleased.clear(); - - for(s in members) { - __pressed.push(s.__getPressed(this)); - __justPressed.push(s.__getJustPressed(this)); - __justReleased.push(s.__getJustReleased(this)); + __pressed.resize(members.length); + __justPressed.resize(members.length); + __justReleased.resize(members.length); + + for(i in 0...members.length) { + __pressed[i] = members[i].__getPressed(this); + __justPressed[i] = members[i].__getJustPressed(this); + __justReleased[i] = members[i].__getJustReleased(this); } var event = EventManager.get(InputSystemEvent).recycle(__pressed, __justPressed, __justReleased, this, id); @@ -411,6 +438,8 @@ class StrumLine extends FlxTypedGroup { var event:SimpleNoteEvent = EventManager.get(SimpleNoteEvent).recycle(note); onNoteDelete.dispatch(event); if (!event.cancelled) { + if (note.isSustainNote && note.sustainParent != null && note.sustainParent.tailCount > 0) + note.sustainParent.tailCount--; note.kill(); notes.remove(note, true); note.destroy(); diff --git a/source/funkin/game/scoring/HitWindowData.hx b/source/funkin/game/scoring/HitWindowData.hx new file mode 100644 index 000000000..e3651e3f3 --- /dev/null +++ b/source/funkin/game/scoring/HitWindowData.hx @@ -0,0 +1,76 @@ +package funkin.game.scoring; + +import haxe.ds.StringMap; + +class HitWindowData +{ + public static function getWindows(preset:WindowPreset):StringMap + { + var map = new StringMap(); + + switch (preset) { + // Old Codename, really forgiving inputs (hard to get bad ratings) + case CNE_CLASSIC: + map.set("sick", 50.0); + map.set("good", 187.5); + map.set("bad", 225.0); + map.set("shit", 250.0); + // Week 7 + case FNF_CLASSIC: + map.set("sick", 33.334); + map.set("good", 125.0025); + map.set("bad", 150.003); + map.set("shit", 166.67); + // V-Slice + case FNF_VSLICE: + map.set("sick", 45.0); + map.set("good", 90.0); + map.set("bad", 135.4); + map.set("shit", 180.0); + // Default, taken from Etterna + case _: + map.set("sick", 37.8); + map.set("good", 75.6); + map.set("bad", 113.4); + map.set("shit", 180.0); + } + + return map; + } + + public static var JUDGE_SCALES:Array = [1.5, 1.33, 1.16, 1.0, 0.84, 0.66, 0.5, 0.33, 0.2]; + public static function scaleWindows(windows:StringMap, scale:Float):StringMap + { + var scaled = new StringMap(); + for (k in windows.keys()) + scaled.set(k, windows.get(k) * scale); + return scaled; + } + + public static function offsetWindows(windows:StringMap, offset:Float):StringMap + { + var adjusted = new StringMap(); + for (k in windows.keys()) + adjusted.set(k, windows.get(k) + offset); + return adjusted; + } +} + +enum abstract WindowPreset(Int) from Int to Int +{ + var DEFAULT = 0; + var CNE_CLASSIC = 1; + var FNF_CLASSIC = 2; + var FNF_VSLICE = 3; + + public function toString():String + { + return switch (cast this : WindowPreset) + { + case CNE_CLASSIC: "Codename (Classic)"; + case FNF_CLASSIC: "Funkin' (Week 7)"; + case FNF_VSLICE: "Funkin' (V-Slice)"; + case _: "Default"; + } + } +} \ No newline at end of file diff --git a/source/funkin/game/scoring/RatingManager.hx b/source/funkin/game/scoring/RatingManager.hx new file mode 100644 index 000000000..2e420ee45 --- /dev/null +++ b/source/funkin/game/scoring/RatingManager.hx @@ -0,0 +1,135 @@ +package funkin.game.scoring; + +import funkin.game.scoring.*; +import funkin.game.scoring.HitWindowData.WindowPreset; + +import haxe.ds.StringMap; + +/** + * Judges note hits and returns a rating. + */ +class RatingManager +{ + public var hitWindows:StringMap; + public var ratingData:Array = []; + + public function new(?preset:WindowPreset):Void + { + var usedPreset = preset != null ? preset : WindowPreset.DEFAULT; + hitWindows = HitWindowData.getWindows(usedPreset); + initDefaultData(hitWindows); + } + + /** + * Returns a rating based on a wimdow of time. + * @param time The timing window to judge. + */ + public function judgeNote(time:Float):Rating + { + for (i => rating in ratingData) + { + if (rating.hittable && rating.window > -1 && time <= rating.window) + { + return rating; + } + } + return ratingData.last(); + } + + /** + * Initializes the default rating data containing the four judgements. + * + * "Sick", "Good", "Bad", "Shit" + */ + public function initDefaultData(windows:StringMap) + { + inline function getWindow(name:String):Float + { + return windows.exists(name) ? windows.get(name) : -1; + } + + addRating({name: "sick", window: getWindow("sick"), accuracy: 1, score: 300, splash: true}); + addRating({name: "good", window: getWindow("good"), accuracy: 0.75, score: 200}); + addRating({name: "bad", window: getWindow("bad"), accuracy: 0.45, score: 100}); + addRating({name: "shit", window: getWindow("shit"), accuracy: 0.25, score: 50}); + } + + public function addRating(data:Dynamic) + { + if (data == null || data.name == null) return; + + var name = data.name.toLowerCase(); + var window = data.window != null + ? data.window + : (hitWindows.exists(name) ? hitWindows.get(name) : -1); + + var newRating:Rating = { + name: name, + window: window, + accuracy: data.accuracy != null ? data.accuracy : 1, + score: data.score != null ? data.score : 0, + splash: data.splash == true, + hittable: data.hittable != null ? data.hittable : true + }; + + var existingIndex = -1; + for (i in 0...ratingData.length) + if (ratingData[i].name == name) + existingIndex = i; + + if (existingIndex >= 0) + ratingData[existingIndex] = newRating; + else + ratingData.push(newRating); + + ratingData.sort((a, b) -> Reflect.compare(a.window, b.window)); + } + + public function removeRating(name:String):Void + { + if (name == null) return; + name = name.toLowerCase(); + ratingData = ratingData.filter(r -> r.name != name); + } + + public function getHitWindow(name:String):Float + { + return hitWindows.exists(name) ? hitWindows.get(name) : -1; + } +} + +@:structInit +final class Rating +{ + /** + * Name of rating. + * + * Also used for the image file name of the rating. + */ + public var name:String = "unknown"; + + /** + * Amount of accuracy given when earning this rating. + */ + public var accuracy:Float = 0.0; + + /** + * MS Timing Window to hit the rating. + */ + public var window:Float = -1; + + /** + * Amount of score given when earning this rating. + */ + public var score:Int = 0; + + /** + * If this rating was hit, a note splash will appear. + */ + @:optional public var splash:Bool = false; + + /** + * Whether the rating is hittable or not. + */ + @:optional public var hittable:Bool = true; +} \ No newline at end of file