diff --git a/hmm.json b/hmm.json index f79a2ca56..a1d78a29f 100644 --- a/hmm.json +++ b/hmm.json @@ -95,8 +95,8 @@ "name": "lime", "type": "git", "dir": null, - "ref": "5634ad7", - "url": "https://github.com/openfl/lime" + "ref": "2447ae6", + "url": "https://github.com/elitemastereric/lime" }, { "name": "openfl", @@ -123,4 +123,4 @@ "version": null } ] -} \ No newline at end of file +} diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index 7f7e2b356..5c9c23ee3 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -16,7 +16,11 @@ typedef BPMChangeEvent = */ class Conductor { - static final STEPS_PER_BEAT:Int = 4; + public static final PIXELS_PER_MS:Float = 0.45; + public static final HIT_WINDOW_MS:Float = 160; + public static final SECONDS_PER_MINUTE:Float = 60; + public static final MILLIS_PER_SECOND:Float = 1000; + public static final STEPS_PER_BEAT:Int = 4; // onBeatHit is called every quarter note // onStepHit is called every sixteenth note @@ -93,7 +97,7 @@ class Conductor static function get_beatLengthMs():Float { // Tied directly to BPM. - return ((60 / bpm) * 1000); + return ((SECONDS_PER_MINUTE / bpm) * MILLIS_PER_SECOND); } /** diff --git a/source/funkin/Controls.hx b/source/funkin/Controls.hx index 46681adbd..88b637e72 100644 --- a/source/funkin/Controls.hx +++ b/source/funkin/Controls.hx @@ -391,6 +391,26 @@ class Controls extends FlxActionSet return byName[name].check(); } + public function getKeysForAction(name:Action):Array { + #if debug + if (!byName.exists(name)) + throw 'Invalid name: $name'; + #end + + return byName[name].inputs.map(function(input) return (input.device == KEYBOARD) ? input.inputID : null) + .filter(function(key) return key != null); + } + + public function getButtonsForAction(name:Action):Array { + #if debug + if (!byName.exists(name)) + throw 'Invalid name: $name'; + #end + + return byName[name].inputs.map(function(input) return (input.device == GAMEPAD) ? input.inputID : null) + .filter(function(key) return key != null); + } + public function getDialogueName(action:FlxActionDigital):String { var input = action.inputs[0]; diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx index 904d2cb45..46e98d8dc 100644 --- a/source/funkin/Highscore.hx +++ b/source/funkin/Highscore.hx @@ -192,6 +192,7 @@ abstract Tallies(RawTallies) bad: 0, good: 0, sick: 0, + killer: 0, totalNotes: 0, totalNotesHit: 0, maxCombo: 0, @@ -213,6 +214,7 @@ typedef RawTallies = var bad:Int; var good:Int; var sick:Int; + var killer:Int; var maxCombo:Int; var isNewHighscore:Bool; diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx index 347454253..bd78a4298 100644 --- a/source/funkin/LatencyState.hx +++ b/source/funkin/LatencyState.hx @@ -2,14 +2,15 @@ package funkin; import flixel.FlxSprite; import flixel.FlxSubState; -import flixel.group.FlxGroup.FlxTypedGroup; import flixel.group.FlxGroup; +import flixel.group.FlxGroup.FlxTypedGroup; import flixel.math.FlxMath; import flixel.sound.FlxSound; import flixel.system.debug.stats.StatsGraph; import flixel.text.FlxText; import flixel.util.FlxColor; import funkin.audio.visualize.PolygonSpectogram; +import funkin.play.notes.NoteSprite; import funkin.ui.CoolStatsGraph; import haxe.Timer; import openfl.events.KeyboardEvent; @@ -17,7 +18,7 @@ import openfl.events.KeyboardEvent; class LatencyState extends MusicBeatSubState { var offsetText:FlxText; - var noteGrp:FlxTypedGroup; + var noteGrp:FlxTypedGroup; var strumLine:FlxSprite; var blocks:FlxTypedGroup; @@ -74,7 +75,7 @@ class LatencyState extends MusicBeatSubState Conductor.forceBPM(60); - noteGrp = new FlxTypedGroup(); + noteGrp = new FlxTypedGroup(); add(noteGrp); diffGrp = new FlxTypedGroup(); @@ -127,7 +128,7 @@ class LatencyState extends MusicBeatSubState for (i in 0...32) { - var note:Note = new Note(Conductor.beatLengthMs * i, 1); + var note:NoteSprite = new NoteSprite(Conductor.beatLengthMs * i); noteGrp.add(note); } @@ -246,8 +247,8 @@ class LatencyState extends MusicBeatSubState FlxG.resetState(); }*/ - noteGrp.forEach(function(daNote:Note) { - daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.audioOffset) - daNote.data.strumTime) * 0.45); + noteGrp.forEach(function(daNote:NoteSprite) { + daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.audioOffset) - daNote.noteData.time) * 0.45); daNote.x = strumLine.x + 30; if (daNote.y < strumLine.y) daNote.alpha = 0.5; diff --git a/source/funkin/Note.hx b/source/funkin/Note.hx deleted file mode 100644 index ea99449b1..000000000 --- a/source/funkin/Note.hx +++ /dev/null @@ -1,301 +0,0 @@ -package funkin; - -import funkin.play.Strumline.StrumlineArrow; -import flixel.FlxSprite; -import flixel.math.FlxMath; -import funkin.noteStuff.NoteBasic.NoteData; -import funkin.noteStuff.NoteBasic.NoteType; -import funkin.play.PlayState; -import funkin.play.Strumline.StrumlineStyle; -import funkin.shaderslmfao.ColorSwap; -import funkin.ui.PreferencesMenu; -import funkin.util.Constants; - -class Note extends FlxSprite -{ - public var data = new NoteData(); - - /** - * code colors for.... code.... - * i think goes in order of left to right - * - * left 0 - * down 1 - * up 2 - * right 3 - */ - public static var codeColors:Array = [0xFFFF22AA, 0xFF00EEFF, 0xFF00CC00, 0xFFCC1111]; - - public var mustPress:Bool = false; - public var followsTime:Bool = true; // used if you want the note to follow the time shit! - public var canBeHit:Bool = false; - public var tooLate:Bool = false; - public var wasGoodHit:Bool = false; - public var prevNote:Note; - - var willMiss:Bool = false; - - public var invisNote:Bool = false; - - public var isSustainNote:Bool = false; - - public var colorSwap:ColorSwap; - - /** the lowercase name of the note, for anim control, i.e. left right up down */ - public var dirName(get, never):String; - - inline function get_dirName() - return data.dirName; - - /** the uppercase name of the note, for anim control, i.e. left right up down */ - public var dirNameUpper(get, never):String; - - inline function get_dirNameUpper() - return data.dirNameUpper; - - /** the lowercase name of the note's color, for anim control, i.e. purple blue green red */ - public var colorName(get, never):String; - - inline function get_colorName() - return data.colorName; - - /** the lowercase name of the note's color, for anim control, i.e. purple blue green red */ - public var colorNameUpper(get, never):String; - - inline function get_colorNameUpper() - return data.colorNameUpper; - - public var highStakes(get, never):Bool; - - inline function get_highStakes() - return data.highStakes; - - public var lowStakes(get, never):Bool; - - inline function get_lowStakes() - return data.lowStakes; - - public static var swagWidth:Float = 160 * 0.7; - public static var PURP_NOTE:Int = 0; - public static var GREEN_NOTE:Int = 2; - public static var BLUE_NOTE:Int = 1; - public static var RED_NOTE:Int = 3; - - // SCORING STUFF - public static var HIT_WINDOW:Float = (10 / 60) * 1000; // 166.67 ms hit window (10 frames at 60fps) - // thresholds are fractions of HIT_WINDOW ^^ - // anything above bad threshold is shit - public static var BAD_THRESHOLD:Float = 0.8; // 125ms , 8 frames - public static var GOOD_THRESHOLD:Float = 0.55; // 91.67ms , 5.5 frames - public static var SICK_THRESHOLD:Float = 0.2; // 33.33ms , 2 frames - - public var noteSpeedMulti:Float = 1; - public var pastHalfWay:Bool = false; - - // anything below sick threshold is sick - public static var arrowColors:Array = [1, 1, 1, 1]; - - // Which note asset to load? - public var style:StrumlineStyle = NORMAL; - - public function new(strumTime:Float = 0, noteData:NoteType, ?prevNote:Note, ?sustainNote:Bool = false, ?style:StrumlineStyle = NORMAL) - { - super(); - - if (prevNote == null) prevNote = this; - - this.prevNote = prevNote; - isSustainNote = sustainNote; - - x += 50; - // MAKE SURE ITS DEFINITELY OFF SCREEN? - y -= 2000; - data.strumTime = strumTime; - - data.noteData = noteData; - - this.style = style; - - if (this.style == null) this.style = StrumlineStyle.NORMAL; - - // TODO: Make this logic more generic - switch (this.style) - { - case PIXEL: - loadGraphic(Paths.image('weeb/pixelUI/arrows-pixels'), true, 17, 17); - - animation.add('greenScroll', [6]); - animation.add('redScroll', [7]); - animation.add('blueScroll', [5]); - animation.add('purpleScroll', [4]); - - if (isSustainNote) - { - loadGraphic(Paths.image('weeb/pixelUI/arrowEnds'), true, 7, 6); - - animation.add('purpleholdend', [4]); - animation.add('greenholdend', [6]); - animation.add('redholdend', [7]); - animation.add('blueholdend', [5]); - - animation.add('purplehold', [0]); - animation.add('greenhold', [2]); - animation.add('redhold', [3]); - animation.add('bluehold', [1]); - } - - setGraphicSize(Std.int(width * Constants.PIXEL_ART_SCALE)); - updateHitbox(); - - default: - frames = Paths.getSparrowAtlas('NOTE_assets'); - - animation.addByPrefix('purpleScroll', 'purple instance'); - animation.addByPrefix('blueScroll', 'blue instance'); - animation.addByPrefix('greenScroll', 'green instance'); - animation.addByPrefix('redScroll', 'red instance'); - - animation.addByPrefix('purpleholdend', 'pruple end hold'); - animation.addByPrefix('greenholdend', 'green hold end'); - animation.addByPrefix('redholdend', 'red hold end'); - animation.addByPrefix('blueholdend', 'blue hold end'); - - animation.addByPrefix('purplehold', 'purple hold piece'); - animation.addByPrefix('greenhold', 'green hold piece'); - animation.addByPrefix('redhold', 'red hold piece'); - animation.addByPrefix('bluehold', 'blue hold piece'); - - setGraphicSize(Std.int(width * 0.7)); - updateHitbox(); - antialiasing = true; - - // colorSwap.colorToReplace = 0xFFF9393F; - // colorSwap.newColor = 0xFF00FF00; - - // color = FlxG.random.color(); - // color.saturation *= 4; - // replaceColor(0xFFC1C1C1, FlxColor.RED); - } - - colorSwap = new ColorSwap(); - shader = colorSwap.shader; - updateColors(); - - x += swagWidth * data.int; - animation.play(data.colorName + 'Scroll'); - - // trace(prevNote); - - if (isSustainNote && prevNote != null) - { - alpha = 0.6; - - if (PreferencesMenu.getPref('downscroll')) angle = 180; - - x += width / 2; - - animation.play(data.colorName + 'holdend'); - - updateHitbox(); - - x -= width / 2; - - if (PlayState.instance.currentStageId.startsWith('school')) x += 30; - - if (prevNote.isSustainNote) - { - prevNote.animation.play(prevNote.colorName + 'hold'); - prevNote.updateHitbox(); - - var scaleThing:Float = Math.round((Conductor.stepLengthMs) * (0.45 * FlxMath.roundDecimal(PlayState.instance.currentChart.scrollSpeed, 2))); - // get them a LIL closer together cuz the antialiasing blurs the edges - if (antialiasing) scaleThing *= 1.0 + (1.0 / prevNote.frameHeight); - prevNote.scale.y = scaleThing / prevNote.frameHeight; - prevNote.updateHitbox(); - } - } - } - - public function alignToSturmlineArrow(arrow:StrumlineArrow):Void - { - x = arrow.x; - - if (isSustainNote && prevNote != null) - { - if (prevNote.isSustainNote) - { - x = prevNote.x; - } - else - { - x += prevNote.width / 2; - x -= width / 2; - } - } - } - - override function destroy() - { - prevNote = null; - - super.destroy(); - } - - public function updateColors():Void - { - colorSwap.update(arrowColors[data.noteData]); - } - - override function update(elapsed:Float) - { - super.update(elapsed); - - // mustPress indicates the player is the one pressing the key - if (mustPress) - { - // miss on the NEXT frame so lag doesnt make u miss notes - if (willMiss && !wasGoodHit) - { - tooLate = true; - canBeHit = false; - } - else - { - if (!pastHalfWay && data.strumTime <= Conductor.songPosition) - { - pastHalfWay = true; - noteSpeedMulti *= 2; - } - - if (data.strumTime > Conductor.songPosition - HIT_WINDOW) - { - // * 0.5 if sustain note, so u have to keep holding it closer to all the way thru! - if (data.strumTime < Conductor.songPosition + (HIT_WINDOW * (isSustainNote ? 0.5 : 1))) canBeHit = true; - } - else - { - canBeHit = true; - willMiss = true; - } - } - } - else - { - canBeHit = false; - - if (data.strumTime <= Conductor.songPosition) wasGoodHit = true; - } - - if (tooLate) - { - if (alpha > 0.3) alpha = 0.3; - } - } - - static public function fromData(data:NoteData, prevNote:Note, isSustainNote = false) - { - var result = new Note(data.strumTime, data.noteData, prevNote, isSustainNote); - result.data = data; - return result; - } -} diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx index 60dcfad38..3943d84ee 100644 --- a/source/funkin/Paths.hx +++ b/source/funkin/Paths.hx @@ -96,14 +96,14 @@ class Paths return getPath('music/$key.$SOUND_EXT', MUSIC, library); } - inline static public function voices(song:String, ?suffix:String) + inline static public function voices(song:String, ?suffix:String = '') { if (suffix == null) suffix = ""; // no suffix, for a sorta backwards compatibility with older-ish voice files return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT'; } - inline static public function inst(song:String, ?suffix:String) + inline static public function inst(song:String, ?suffix:String = '') { return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT'; } diff --git a/source/funkin/PlayerSettings.hx b/source/funkin/PlayerSettings.hx index b9ad87a93..1b64d26c2 100644 --- a/source/funkin/PlayerSettings.hx +++ b/source/funkin/PlayerSettings.hx @@ -2,6 +2,7 @@ package funkin; import funkin.Controls; import flixel.FlxCamera; +import funkin.input.PreciseInputManager; import flixel.input.actions.FlxActionInput; import flixel.input.gamepad.FlxGamepad; import flixel.util.FlxSignal; @@ -52,6 +53,9 @@ class PlayerSettings } if (useDefault) controls.setKeyboardScheme(Solo); + + // Apply loaded settings. + PreciseInputManager.instance.initializeKeys(controls); } function addGamepad(gamepad:FlxGamepad) diff --git a/source/funkin/Section.hx b/source/funkin/Section.hx deleted file mode 100644 index f239baaad..000000000 --- a/source/funkin/Section.hx +++ /dev/null @@ -1,33 +0,0 @@ -package funkin; - -import funkin.noteStuff.NoteBasic.NoteData; - -typedef SwagSection = -{ - var sectionNotes:Array; - var lengthInSteps:Int; - var typeOfSection:Int; - var mustHitSection:Bool; - var bpm:Float; - var changeBPM:Bool; - var altAnim:Bool; -} - -class Section -{ - public var sectionNotes:Array = []; - - public var lengthInSteps:Int = 16; - public var typeOfSection:Int = 0; - public var mustHitSection:Bool = true; - - /** - * Copies the first section into the second section! - */ - public static var COPYCAT:Int = 0; - - public function new(lengthInSteps:Int = 16) - { - this.lengthInSteps = lengthInSteps; - } -} diff --git a/source/funkin/SongLoad.hx b/source/funkin/SongLoad.hx deleted file mode 100644 index ca3bc72d0..000000000 --- a/source/funkin/SongLoad.hx +++ /dev/null @@ -1,325 +0,0 @@ -package funkin; - -import funkin.Section.SwagSection; -import funkin.noteStuff.NoteBasic.NoteData; -import funkin.play.PlayState; -import haxe.Json; -import lime.utils.Assets; - -typedef SwagSong = -{ - var song:String; - var notes:FunnyNotes; - var difficulties:Array; - var noteMap:Map>; - var bpm:Float; - var needsVoices:Bool; - var voiceList:Array; - var speed:FunnySpeed; - var speedMap:Map; - - var player1:String; - var player2:String; - var validScore:Bool; - var extraNotes:Map>; -} - -typedef FunnySpeed = -{ - var ?easy:Float; - var ?normal:Float; - var ?hard:Float; -} - -typedef FunnyNotes = -{ - var ?easy:Array; - var ?normal:Array; - var ?hard:Array; -} - -class SongLoad -{ - public static var curDiff:String = 'normal'; - public static var curNotes:Array; - public static var songData:SwagSong; - - public static function loadFromJson(jsonInput:String, ?folder:String):SwagSong - { - var rawJson:String = null; - try - { - rawJson = Assets.getText(Paths.json('songs/${folder.toLowerCase()}/${jsonInput.toLowerCase()}')).trim(); - } - catch (e) - { - trace('Failed to load song data: ${e}'); - rawJson = null; - } - - if (rawJson == null) - { - return null; - } - - while (!rawJson.endsWith("}")) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return parseJSONshit(rawJson); - } - - public static function getSong(?diff:String):Array - { - if (diff == null) diff = SongLoad.curDiff; - - var songShit:Array = []; - - // THIS IS OVERWRITTEN, WILL BE DEPRECTATED AND REPLACED SOOOOON - if (songData != null) - { - switch (diff) - { - case 'easy': - songShit = songData.notes.easy; - case 'normal': - songShit = songData.notes.normal; - case 'hard': - songShit = songData.notes.hard; - } - } - - checkAndCreateNotemap(curDiff); - - songShit = songData.noteMap[diff]; - - return songShit; - } - - public static function checkAndCreateNotemap(diff:String):Void - { - if (songData == null || songData.noteMap == null) return; - if (songData.noteMap[diff] == null) songData.noteMap[diff] = []; - } - - public static function getSpeed(?diff:String):Float - { - if (PlayState.instance != null && PlayState.instance.currentChart != null) - { - return getSpeed_NEW(diff); - } - - if (diff == null) diff = SongLoad.curDiff; - - var speedShit:Float = 1; - - // all this shit is overridden by the thing that loads it from speedMap Map object!!! - // replace and delete later! - switch (diff) - { - case 'easy': - speedShit = songData?.speed?.easy ?? 1.0; - case 'normal': - speedShit = songData?.speed?.normal ?? 1.0; - case 'hard': - speedShit = songData?.speed?.hard ?? 1.0; - } - - if (songData?.speedMap == null || songData?.speedMap[diff] == null) - { - speedShit = 1; - } - else - { - speedShit = songData.speedMap[diff]; - } - - return speedShit; - } - - public static function getSpeed_NEW(?diff:String):Float - { - if (PlayState.instance == null - || PlayState.instance.currentChart == null - || PlayState.instance.currentChart.scrollSpeed == 0.0) return 1.0; - - return PlayState.instance.currentChart.scrollSpeed; - } - - public static function getDefaultSwagSong():SwagSong - { - return { - song: 'Test', - notes: {easy: [], normal: [], hard: []}, - difficulties: ["easy", "normal", "hard"], - noteMap: new Map(), - speedMap: new Map(), - bpm: 150, - needsVoices: true, - player1: 'bf', - player2: 'dad', - speed: - { - easy: 1, - normal: 1, - hard: 1 - }, - validScore: false, - voiceList: ["BF", "BF-pixel"], - extraNotes: [] - }; - } - - public static function getDefaultNoteData():NoteData - { - return new NoteData(); - } - - /** - * Casts the an array to NOTE data (for LOADING shit from json usually) - */ - public static function castArrayToNoteData(noteStuff:Array) - { - if (noteStuff == null) return; - - for (sectionIndex => section in noteStuff) - { - if (section == null || section.sectionNotes == null) continue; - for (noteIndex => noteDataArray in section.sectionNotes) - { - var arrayDipshit:Array = cast noteDataArray; // crackhead - - if (arrayDipshit != null) // array isnt null, that means it loaded it as an array and needs to be manually parsed? - { - // at this point noteStuff[sectionIndex].sectionNotes[noteIndex] is an array because of the cast from the first line in this function - // so this line right here turns it back into the NoteData typedef type because of another bastard cast - noteStuff[sectionIndex].sectionNotes[noteIndex] = cast SongLoad.getDefaultNoteData(); // turn it from an array (because of the cast), back to noteData? yeah that works - - noteStuff[sectionIndex].sectionNotes[noteIndex].strumTime = arrayDipshit[0]; - noteStuff[sectionIndex].sectionNotes[noteIndex].noteData = arrayDipshit[1]; - noteStuff[sectionIndex].sectionNotes[noteIndex].sustainLength = arrayDipshit[2]; - if (arrayDipshit.length > 3) - { - noteStuff[sectionIndex].sectionNotes[noteIndex].noteKind = arrayDipshit[3]; - } - } - else if (noteDataArray != null) - { - // array is NULL, so it checks if noteDataArray (doesnt exactly NEED to be an 'array' is also null or not.) - // At this point it should be an OBJECT that can be easily casted!!! - - noteStuff[sectionIndex].sectionNotes[noteIndex] = cast noteDataArray; - } - else - throw "shit brokey"; // i actually dont know how throw works lol - } - } - } - - /** - * Cast notedata to ARRAY (usually used for level SAVING) - */ - public static function castNoteDataToArray(noteStuff:Array) - { - if (noteStuff == null) return; - - for (sectionIndex => section in noteStuff) - { - for (noteIndex => noteTypeDefShit in section.sectionNotes) - { - var dipshitArray:Array = [ - noteTypeDefShit.strumTime, - noteTypeDefShit.noteData, - noteTypeDefShit.sustainLength, - noteTypeDefShit.noteKind - ]; - - noteStuff[sectionIndex].sectionNotes[noteIndex] = cast dipshitArray; - } - } - } - - public static function castNoteDataToNoteData(noteStuff:Array) - { - if (noteStuff == null) return; - - for (sectionIndex => section in noteStuff) - { - for (noteIndex => noteTypedefShit in section.sectionNotes) - { - trace(noteTypedefShit); - noteStuff[sectionIndex].sectionNotes[noteIndex] = noteTypedefShit; - } - } - } - - public static function parseJSONshit(rawJson:String):SwagSong - { - var songParsed:Dynamic; - try - { - songParsed = Json.parse(rawJson); - } - catch (e) - { - FlxG.log.warn("Error parsing JSON: " + e.message); - trace("Error parsing JSON: " + e.message); - return null; - } - - var swagShit:SwagSong = cast songParsed.song; - swagShit.difficulties = []; // reset it to default before load - swagShit.noteMap = new Map(); - swagShit.speedMap = new Map(); - for (diff in Reflect.fields(songParsed.song.notes)) - { - swagShit.difficulties.push(diff); - swagShit.noteMap[diff] = cast Reflect.field(songParsed.song.notes, diff); - - castArrayToNoteData(swagShit.noteMap[diff]); - - // castNoteDataToNoteData(swagShit.noteMap[diff]); - - /* - switch (diff) - { - case "easy": - castArrayToNoteData(swagShit.notes.hard); - - case "normal": - castArrayToNoteData(swagShit.notes.normal); - case "hard": - castArrayToNoteData(swagShit.notes.hard); - } - */ - } - - for (diff in swagShit.difficulties) - { - swagShit.speedMap[diff] = cast Reflect.field(songParsed.song.speed, diff); - } - - // trace(swagShit.noteMap.toString()); - // trace(swagShit.speedMap.toString()); - // trace('that was just notemap string lol'); - - swagShit.validScore = true; - - trace("SONG SHIT ABOUTTA WEEK AGOOO"); - for (field in Reflect.fields(Json.parse(rawJson).song.speed)) - { - // swagShit.speed[field] = Reflect.field(Json.parse(rawJson).song.speed, field); - // swagShit.notes[field] = Reflect.field(Json.parse(rawJson).song.notes, field); - // trace(swagShit.notes[field]); - } - - // swagShit.notes = cast Json.parse(rawJson).song.notes[SongLoad.curDiff]; // by default uses - - trace('THAT SHIT WAS JUST THE NORMAL NOTES!!!'); - songData = swagShit; - // curNotes = songData.notes.get('normal'); - - return swagShit; - } -} diff --git a/source/funkin/import.hx b/source/funkin/import.hx index f54ccea86..9aa99fade 100644 --- a/source/funkin/import.hx +++ b/source/funkin/import.hx @@ -9,6 +9,7 @@ import flixel.FlxG; // This one in particular causes a compile error if you're u using Lambda; using StringTools; using funkin.util.tools.ArrayTools; +using funkin.util.tools.ArraySortTools; using funkin.util.tools.IteratorTools; using funkin.util.tools.MapTools; using funkin.util.tools.StringTools; diff --git a/source/funkin/input/PreciseInputManager.hx b/source/funkin/input/PreciseInputManager.hx new file mode 100644 index 000000000..11a3c2007 --- /dev/null +++ b/source/funkin/input/PreciseInputManager.hx @@ -0,0 +1,303 @@ +package funkin.input; + +import openfl.ui.Keyboard; +import funkin.play.notes.NoteDirection; +import flixel.input.keyboard.FlxKeyboard.FlxKeyInput; +import openfl.events.KeyboardEvent; +import flixel.FlxG; +import flixel.input.FlxInput.FlxInputState; +import flixel.input.FlxKeyManager; +import flixel.input.keyboard.FlxKey; +import flixel.input.keyboard.FlxKeyList; +import flixel.util.FlxSignal.FlxTypedSignal; +import haxe.Int64; +import lime.ui.KeyCode; +import lime.ui.KeyModifier; + +/** + * A precise input manager that: + * - Records the exact timestamp of when a key was pressed or released + * - Only records key presses for keys bound to game inputs (up/down/left/right) + */ +class PreciseInputManager extends FlxKeyManager +{ + public static var instance(get, null):PreciseInputManager; + + static function get_instance():PreciseInputManager + { + return instance ?? (instance = new PreciseInputManager()); + } + + static final MS_TO_US:Int64 = 1000; + static final US_TO_NS:Int64 = 1000; + static final MS_TO_NS:Int64 = MS_TO_US * US_TO_NS; + + static final DIRECTIONS:Array = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT]; + + public var onInputPressed:FlxTypedSignalVoid>; + public var onInputReleased:FlxTypedSignalVoid>; + + /** + * The list of keys that are bound to game inputs (up/down/left/right). + */ + var _keyList:Array; + + /** + * The direction that a given key is bound to. + */ + var _keyListDir:Map; + + /** + * The timestamp at which a given note direction was last pressed. + */ + var _dirPressTimestamps:Map; + + /** + * The timestamp at which a given note direction was last released. + */ + var _dirReleaseTimestamps:Map; + + public function new() + { + super(PreciseInputList.new); + + _keyList = []; + _dirPressTimestamps = new Map(); + _dirReleaseTimestamps = new Map(); + _keyListDir = new Map(); + + FlxG.stage.removeEventListener(KeyboardEvent.KEY_DOWN, onKeyDown); + FlxG.stage.removeEventListener(KeyboardEvent.KEY_UP, onKeyUp); + FlxG.stage.application.window.onKeyDownPrecise.add(handleKeyDown); + FlxG.stage.application.window.onKeyUpPrecise.add(handleKeyUp); + + preventDefaultKeys = getPreventDefaultKeys(); + + onInputPressed = new FlxTypedSignalVoid>(); + onInputReleased = new FlxTypedSignalVoid>(); + } + + public static function getKeysForDirection(controls:Controls, noteDirection:NoteDirection) + { + return switch (noteDirection) + { + case NoteDirection.LEFT: controls.getKeysForAction(NOTE_LEFT); + case NoteDirection.DOWN: controls.getKeysForAction(NOTE_DOWN); + case NoteDirection.UP: controls.getKeysForAction(NOTE_UP); + case NoteDirection.RIGHT: controls.getKeysForAction(NOTE_RIGHT); + }; + } + + /** + * Returns a precise timestamp, measured in nanoseconds. + * Timestamp is only useful for comparing against other timestamps. + * + * @return Int64 + */ + @:access(lime._internal.backend.native.NativeCFFI) + public static function getCurrentTimestamp():Int64 + { + #if html5 + // NOTE: This timestamp isn't that precise on standard HTML5 builds. + // This is because of browser safeguards against timing attacks. + // See https://web.dev/coop-coep to enable headers which allow for more precise timestamps. + return js.Browser.window.performance.now() * MS_TO_NS; + #elseif cpp + // NOTE: If the game hard crashes on this line, rebuild Lime! + // `lime rebuild windows -clean` + return lime._internal.backend.native.NativeCFFI.lime_sdl_get_ticks() * MS_TO_NS; + #else + throw "Eric didn't implement precise timestamps on this platform!"; + #end + } + + static function getPreventDefaultKeys():Array + { + return []; + } + + /** + * Call this whenever the user's inputs change. + */ + public function initializeKeys(controls:Controls):Void + { + clearKeys(); + + for (noteDirection in DIRECTIONS) + { + var keys = getKeysForDirection(controls, noteDirection); + for (key in keys) + { + var input = new FlxKeyInput(key); + _keyList.push(key); + _keyListArray.push(input); + _keyListMap.set(key, input); + _keyListDir.set(key, noteDirection); + } + } + } + + /** + * Get the time, in nanoseconds, since the given note direction was last pressed. + * @param noteDirection The note direction to check. + * @return An Int64 representing the time since the given note direction was last pressed. + */ + public function getTimeSincePressed(noteDirection:NoteDirection):Int64 + { + return getCurrentTimestamp() - _dirPressTimestamps.get(noteDirection); + } + + /** + * Get the time, in nanoseconds, since the given note direction was last released. + * @param noteDirection The note direction to check. + * @return An Int64 representing the time since the given note direction was last released. + */ + public function getTimeSinceReleased(noteDirection:NoteDirection):Int64 + { + return getCurrentTimestamp() - _dirReleaseTimestamps.get(noteDirection); + } + + // TODO: Why doesn't this work? + // @:allow(funkin.input.PreciseInputManager.PreciseInputList) + public function getInputByKey(key:FlxKey):FlxKeyInput + { + return _keyListMap.get(key); + } + + public function getDirectionForKey(key:FlxKey):NoteDirection + { + return _keyListDir.get(key); + } + + function handleKeyDown(keyCode:KeyCode, _:KeyModifier, timestamp:Int64):Void + { + var key:FlxKey = convertKeyCode(keyCode); + if (_keyList.indexOf(key) == -1) return; + + // TODO: Remove this line with SDL3 when timestamps change meaning. + // This is because SDL3's timestamps are measured in nanoseconds, not milliseconds. + timestamp *= MS_TO_NS; + + updateKeyStates(key, true); + + if (getInputByKey(key) ?.justPressed ?? false) + { + onInputPressed.dispatch( + { + noteDirection: getDirectionForKey(key), + timestamp: timestamp + }); + _dirPressTimestamps.set(getDirectionForKey(key), timestamp); + } + } + + function handleKeyUp(keyCode:KeyCode, _:KeyModifier, timestamp:Int64):Void + { + var key:FlxKey = convertKeyCode(keyCode); + if (_keyList.indexOf(key) == -1) return; + + // TODO: Remove this line with SDL3 when timestamps change meaning. + // This is because SDL3's timestamps are measured in nanoseconds, not milliseconds. + timestamp *= MS_TO_NS; + + updateKeyStates(key, false); + + if (getInputByKey(key) ?.justReleased ?? false) + { + onInputReleased.dispatch( + { + noteDirection: getDirectionForKey(key), + timestamp: timestamp + }); + _dirReleaseTimestamps.set(getDirectionForKey(key), timestamp); + } + } + + static function convertKeyCode(input:KeyCode):FlxKey + { + @:privateAccess + { + return Keyboard.__convertKeyCode(input); + } + } + + function clearKeys():Void + { + _keyListArray = []; + _keyListMap.clear(); + _keyListDir.clear(); + } +} + +class PreciseInputList extends FlxKeyList +{ + var _preciseInputManager:PreciseInputManager; + + public function new(state:FlxInputState, preciseInputManager:FlxKeyManager) + { + super(state, preciseInputManager); + + _preciseInputManager = cast preciseInputManager; + } + + static function getKeysForDir(noteDir:NoteDirection):Array + { + return PreciseInputManager.getKeysForDirection(PlayerSettings.player1.controls, noteDir); + } + + function isKeyValid(key:FlxKey):Bool + { + @:privateAccess + { + return _preciseInputManager._keyListMap.exists(key); + } + } + + public function checkFlxKey(key:FlxKey):Bool + { + if (isKeyValid(key)) return check(cast key); + return false; + } + + public function checkDir(noteDir:NoteDirection):Bool + { + for (key in getKeysForDir(noteDir)) + { + if (check(_preciseInputManager.getInputByKey(key) ?.ID)) return true; + } + return false; + } + + public var NOTE_LEFT(get, never):Bool; + + function get_NOTE_LEFT():Bool + return checkDir(NoteDirection.LEFT); + + public var NOTE_DOWN(get, never):Bool; + + function get_NOTE_DOWN():Bool + return checkDir(NoteDirection.DOWN); + + public var NOTE_UP(get, never):Bool; + + function get_NOTE_UP():Bool + return checkDir(NoteDirection.UP); + + public var NOTE_RIGHT(get, never):Bool; + + function get_NOTE_RIGHT():Bool + return checkDir(NoteDirection.RIGHT); +} + +typedef PreciseInputEvent = +{ + /** + * The direction of the input. + */ + noteDirection:NoteDirection, + + /** + * The timestamp of the input. Measured in nanoseconds. + */ + timestamp:Int64, +}; diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx index 95922ded1..3f29ad833 100644 --- a/source/funkin/modding/events/ScriptEvent.hx +++ b/source/funkin/modding/events/ScriptEvent.hx @@ -1,10 +1,12 @@ package funkin.modding.events; +import funkin.play.song.SongData.SongNoteData; import flixel.FlxState; import flixel.FlxSubState; -import funkin.noteStuff.NoteBasic.NoteDir; +import funkin.play.notes.NoteSprite; import funkin.play.cutscene.dialogue.Conversation; import funkin.play.Countdown.CountdownStep; +import funkin.play.notes.NoteDirection; import openfl.events.EventType; import openfl.events.KeyboardEvent; @@ -344,7 +346,7 @@ class NoteScriptEvent extends ScriptEvent * The note associated with this event. * You cannot replace it, but you can edit it. */ - public var note(default, null):Note; + public var note(default, null):NoteSprite; /** * The combo count as it is with this event. @@ -357,7 +359,7 @@ class NoteScriptEvent extends ScriptEvent */ public var playSound(default, default):Bool; - public function new(type:ScriptEventType, note:Note, comboCount:Int = 0, cancelable:Bool = false):Void + public function new(type:ScriptEventType, note:NoteSprite, comboCount:Int = 0, cancelable:Bool = false):Void { super(type, cancelable); this.note = note; @@ -379,7 +381,7 @@ class GhostMissNoteScriptEvent extends ScriptEvent /** * The direction that was mistakenly pressed. */ - public var dir(default, null):NoteDir; + public var dir(default, null):NoteDirection; /** * Whether there was a note within judgement range when this ghost note was pressed. @@ -407,7 +409,7 @@ class GhostMissNoteScriptEvent extends ScriptEvent */ public var playAnim(default, default):Bool; - public function new(dir:NoteDir, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void + public function new(dir:NoteDirection, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void { super(ScriptEvent.NOTE_GHOST_MISS, true); this.dir = dir; @@ -575,19 +577,19 @@ class SongLoadScriptEvent extends ScriptEvent * The note associated with this event. * You cannot replace it, but you can edit it. */ - public var notes(default, set):Array; + public var notes(default, set):Array; public var id(default, null):String; public var difficulty(default, null):String; - function set_notes(notes:Array):Array + function set_notes(notes:Array):Array { this.notes = notes; return this.notes; } - public function new(id:String, difficulty:String, notes:Array):Void + public function new(id:String, difficulty:String, notes:Array):Void { super(ScriptEvent.SONG_LOADED, false); this.id = id; diff --git a/source/funkin/noteStuff/NoteBasic.hx b/source/funkin/noteStuff/NoteBasic.hx deleted file mode 100644 index c1900710f..000000000 --- a/source/funkin/noteStuff/NoteBasic.hx +++ /dev/null @@ -1,197 +0,0 @@ -package funkin.noteStuff; - -import flixel.FlxSprite; -import flixel.text.FlxText; - -typedef RawNoteData = -{ - var strumTime:Float; - var noteData:NoteType; - var sustainLength:Float; - var altNote:String; - var noteKind:NoteKind; -} - -@:forward -abstract NoteData(RawNoteData) -{ - public function new(strumTime = 0.0, noteData:NoteType = 0, sustainLength = 0.0, altNote = "", noteKind = NORMAL) - { - this = - { - strumTime: strumTime, - noteData: noteData, - sustainLength: sustainLength, - altNote: altNote, - noteKind: noteKind - } - } - - public var note(get, never):NoteType; - - inline function get_note() - return this.noteData.value; - - public var int(get, never):Int; - - inline function get_int() - return this.noteData.int; - - public var dir(get, never):NoteDir; - - inline function get_dir() - return this.noteData.value; - - public var dirName(get, never):String; - - inline function get_dirName() - return dir.name; - - public var dirNameUpper(get, never):String; - - inline function get_dirNameUpper() - return dir.nameUpper; - - public var color(get, never):NoteColor; - - inline function get_color() - return this.noteData.value; - - public var colorName(get, never):String; - - inline function get_colorName() - return color.name; - - public var colorNameUpper(get, never):String; - - inline function get_colorNameUpper() - return color.nameUpper; - - public var highStakes(get, never):Bool; - - inline function get_highStakes() - return this.noteData.highStakes; - - public var lowStakes(get, never):Bool; - - inline function get_lowStakes() - return this.noteData.lowStakes; -} - -enum abstract NoteType(Int) from Int to Int -{ - // public var raw(get, never):Int; - // inline function get_raw() return this; - public var int(get, never):Int; - - inline function get_int() - return this < 0 ? -this : this % 4; - - public var value(get, never):NoteType; - - inline function get_value() - return int; - - public var highStakes(get, never):Bool; - - inline function get_highStakes() - return this > 3; - - public var lowStakes(get, never):Bool; - - inline function get_lowStakes() - return this < 0; -} - -@:forward -enum abstract NoteDir(NoteType) from Int to Int from NoteType -{ - var LEFT = 0; - var DOWN = 1; - var UP = 2; - var RIGHT = 3; - var value(get, never):NoteDir; - - inline function get_value() - return this.value; - - public var name(get, never):String; - - function get_name() - { - return switch (value) - { - case LEFT: "left"; - case DOWN: "down"; - case UP: "up"; - case RIGHT: "right"; - } - } - - public var nameUpper(get, never):String; - - function get_nameUpper() - { - return switch (value) - { - case LEFT: "LEFT"; - case DOWN: "DOWN"; - case UP: "UP"; - case RIGHT: "RIGHT"; - } - } -} - -@:forward -enum abstract NoteColor(NoteType) from Int to Int from NoteType -{ - var PURPLE = 0; - var BLUE = 1; - var GREEN = 2; - var RED = 3; - var value(get, never):NoteColor; - - inline function get_value() - return this.value; - - public var name(get, never):String; - - function get_name() - { - return switch (value) - { - case PURPLE: "purple"; - case BLUE: "blue"; - case GREEN: "green"; - case RED: "red"; - } - } - - public var nameUpper(get, never):String; - - function get_nameUpper() - { - return switch (value) - { - case PURPLE: "PURPLE"; - case BLUE: "BLUE"; - case GREEN: "GREEN"; - case RED: "RED"; - } - } -} - -enum abstract NoteKind(String) from String to String -{ - /** - * The default note type. - */ - var NORMAL = "normal"; - - // Testing shiz - var PYRO_LIGHT = "pyro_light"; - var PYRO_KICK = "pyro_kick"; - var PYRO_TOSS = "pyro_toss"; - var PYRO_COCK = "pyro_cock"; // lol - var PYRO_SHOOT = "pyro_shoot"; -} diff --git a/source/funkin/noteStuff/NoteEvent.hx b/source/funkin/noteStuff/NoteEvent.hx deleted file mode 100644 index 2d0c60073..000000000 --- a/source/funkin/noteStuff/NoteEvent.hx +++ /dev/null @@ -1,12 +0,0 @@ -package funkin.noteStuff; - -import funkin.noteStuff.NoteBasic.NoteType; -import funkin.play.Strumline.StrumlineStyle; - -class NoteEvent extends Note -{ - public function new(strumTime:Float = 0, noteData:NoteType, ?prevNote:Note, ?sustainNote:Bool = false, ?style:StrumlineStyle = NORMAL) - { - super(strumTime, noteData, prevNote, sustainNote, style); - } -} diff --git a/source/funkin/noteStuff/NoteUtil.hx b/source/funkin/noteStuff/NoteUtil.hx deleted file mode 100644 index a36c32482..000000000 --- a/source/funkin/noteStuff/NoteUtil.hx +++ /dev/null @@ -1,98 +0,0 @@ -package funkin.noteStuff; - -import haxe.Json; -import openfl.Assets; - -/** - * Just various functions that IDK where to put em!!! - * Semi-temp for now? the note stuff is super clutter-y right now - * so I am putting this new stuff here right now XDD - * - * A lot of this stuff can probably be moved to where appropriate! - * i dont care about NoteUtil.hx at all!!! - */ -class NoteUtil -{ - /** - * IDK THING FOR BOTH LOL! DIS SHIT HACK-Y - * @param jsonPath - * @return Map> - */ - public static function loadSongEvents(jsonPath:String):Map> - { - return parseSongEvents(loadSongEventFromJson(jsonPath)); - } - - public static function loadSongEventFromJson(jsonPath:String):Array - { - var daEvents:Array; - daEvents = cast Json.parse(Assets.getText(jsonPath)).events; // DUMB LIL DETAIL HERE: MAKE SURE THAT .events IS THERE?? - trace('GET JSON SONG EVENTS:'); - trace(daEvents); - return daEvents; - } - - /** - * Parses song event json stuff into a neater lil map grouping? - * @param songEvents - */ - public static function parseSongEvents(songEvents:Array):Map> - { - var songData:Map> = new Map(); - - for (songEvent in songEvents) - { - trace(songEvent); - if (songData[songEvent.t] == null) songData[songEvent.t] = []; - - songData[songEvent.t].push({songEventType: songEvent.e, value: songEvent.v, activated: false}); - } - - trace("FINISH SONG EVENTS!"); - trace(songData); - - return songData; - } - - public static function checkSongEvents(songData:Map>, time:Float) - { - for (eventGrp in songData.keys()) - { - if (time >= eventGrp) - { - for (events in songData[eventGrp]) - { - if (!events.activated) - { - // TURN TO NICER SWITCH STATEMENT CHECKER OF EVENT TYPES!! - trace(events.value); - trace(eventGrp); - trace(Conductor.songPosition); - events.activated = true; - } - } - } - } - } -} - -typedef SongEventInfo = -{ - var songEventType:SongEventType; - var value:Dynamic; - var activated:Bool; -} - -typedef SongEvent = -{ - var t:Int; - var e:SongEventType; - var v:Dynamic; -} - -enum abstract SongEventType(String) -{ - var FocusCamera; - var PlayCharAnim; - var Trace; -} diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 6dfbfcf65..5583b7fed 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -1,5 +1,6 @@ package funkin.play; +import haxe.Int64; import flixel.addons.display.FlxPieDial; import flixel.addons.transition.FlxTransitionableState; import flixel.FlxCamera; @@ -7,24 +8,21 @@ import flixel.FlxObject; import flixel.FlxSprite; import flixel.FlxState; import flixel.FlxSubState; -import flixel.group.FlxGroup.FlxTypedGroup; import flixel.input.keyboard.FlxKey; import flixel.math.FlxMath; import flixel.math.FlxPoint; import flixel.math.FlxRect; -import flixel.sound.FlxSound; import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.ui.FlxBar; import flixel.util.FlxColor; -import flixel.util.FlxSort; import flixel.util.FlxTimer; import funkin.audio.VoicesGroup; import funkin.Highscore.Tallies; +import funkin.input.PreciseInputManager; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventDispatcher; -import funkin.Note; import funkin.play.character.BaseCharacter; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.cutscene.dialogue.Conversation; @@ -32,17 +30,17 @@ import funkin.play.cutscene.dialogue.ConversationDataParser; import funkin.play.cutscene.VanillaCutscenes; import funkin.play.cutscene.VideoCutscene; import funkin.play.event.SongEventData.SongEventParser; +import funkin.play.notes.NoteSprite; +import funkin.play.notes.NoteDirection; +import funkin.play.notes.Strumline; import funkin.play.scoring.Scoring; import funkin.play.song.Song; import funkin.play.song.SongData.SongDataParser; import funkin.play.song.SongData.SongEventData; import funkin.play.song.SongData.SongNoteData; import funkin.play.song.SongData.SongPlayableChar; -import funkin.play.song.SongValidator; import funkin.play.stage.Stage; import funkin.play.stage.StageData.StageDataParser; -import funkin.play.Strumline.StrumlineArrow; -import funkin.play.Strumline.StrumlineStyle; import funkin.ui.PopUpStuff; import funkin.ui.PreferencesMenu; import funkin.ui.stageBuildShit.StageOffsetSubState; @@ -93,6 +91,12 @@ class PlayState extends MusicBeatState */ public static var instance:PlayState = null; + /** + * This sucks. We need this because FlxG.resetState(); assumes the constructor has no arguments. + * @see https://github.com/HaxeFlixel/flixel/issues/2541 + */ + static var lastParams:PlayStateParams = null; + /** * PUBLIC INSTANCE VARIABLES * Public instance variables should be used for information that must be reset or dereferenced @@ -118,18 +122,6 @@ class PlayState extends MusicBeatState */ public var currentStage:Stage = null; - /** - * Data for the current difficulty for the current song. - * Includes chart data, scroll speed, and other information. - */ - public var currentChart(get, null):SongDifficulty; - - /** - * The internal ID of the currently active Stage. - * Used to retrieve the data required to build the `currentStage`. - */ - public var currentStageId(get, null):String; - /** * Gets set to true when the PlayState needs to reset (player opted to restart or died). * Gets disabled once resetting happens. @@ -145,23 +137,24 @@ class PlayState extends MusicBeatState /** * The player's current health. * The default maximum health is 2.0, and the default starting health is 1.0. + * TODO: Refactor this to [0.0, 1.0] */ public var health:Float = 1; /** * The player's current score. + * TODO: Move this to its own class. */ public var songScore:Int = 0; /** * An empty FlxObject contained in the scene. - * The current gameplay camera will be centered on this object. Tween its position to move the camera smoothly. + * The current gameplay camera will always follow this object. Tween its position to move the camera smoothly. * - * This is an FlxSprite for two reasons: - * 1. It needs to be an object in the scene for the camera to be configured to follow it. - * 2. It needs to be an FlxSprite to allow a graphic (optionally, for debug purposes) to be drawn on it. + * It needs to be an object in the scene for the camera to be configured to follow it. + * We optionally make this an FlxSprite so we can draw a debug graphic with it. */ - public var cameraFollowPoint:FlxSprite = new FlxSprite(0, 0); + public var cameraFollowPoint:FlxObject; /** * The camera follow point from the last stage. @@ -229,17 +222,23 @@ class PlayState extends MusicBeatState */ public var currentConversation:Conversation; + /** + * Key press inputs which have been received but not yet processed. + * These are encoded with an OS timestamp, so they + **/ + var inputPressQueue:Array = []; + + /** + * Key release inputs which have been received but not yet processed. + * These are encoded with an OS timestamp, so they + **/ + var inputReleaseQueue:Array = []; + /** * PRIVATE INSTANCE VARIABLES * Private instance variables should be used for information that must be reset or dereferenced * every time the state is reset, but should not be accessed externally. */ - /** - * The Array containing the notes that are not currently on the screen. - * The `update()` function regularly shifts these out to add new notes to the screen. - */ - var inactiveNotes:Array; - /** * The Array containing the upcoming song events. * The `update()` function regularly shifts these out to trigger events. @@ -279,14 +278,17 @@ class PlayState extends MusicBeatState */ var vocals:VoicesGroup; + #if discord_rpc + // Discord RPC variables + var storyDifficultyText:String = ''; + var iconRPC:String = ''; + var detailsText:String = ''; + var detailsPausedText:String = ''; + #end + /** * RENDER OBJECTS */ - /** - * The SpriteGroup containing the notes that are currently on the screen or are about to be on the screen. - */ - var activeNotes:FlxTypedGroup = null; - /** * The FlxText which displays the current score. */ @@ -322,7 +324,7 @@ class PlayState extends MusicBeatState /** * The sprite group containing opponent's strumline notes. */ - public var enemyStrumline:Strumline; + public var opponentStrumline:Strumline; /** * The camera which contains, and controls visibility of, the user interface elements. @@ -339,6 +341,14 @@ class PlayState extends MusicBeatState */ public var camCutscene:FlxCamera; + /** + * The combo popups. Includes the real-time combo counter and the rating. + */ + var comboPopUps:PopUpStuff; + + /** + * The circular sprite that appears while the user is holding down the Skip Cutscene button. + */ var skipTimer:FlxPieDial; /** @@ -360,34 +370,54 @@ class PlayState extends MusicBeatState return this.subState != null; } - var gfSpeed:Int = 1; - var generatedMusic:Bool = false; + /** + * Data for the current difficulty for the current song. + * Includes chart data, scroll speed, and other information. + */ + public var currentChart(get, null):SongDifficulty; - var grpNoteSplashes:FlxTypedGroup; - var comboPopUps:PopUpStuff; - var perfectMode:Bool = false; - var previousFrameTime:Int = 0; - var songTime:Float = 0; - - #if discord_rpc - // Discord RPC variables - var storyDifficultyText:String = ''; - var iconRPC:String = ''; - var songLength:Float = 0; - var detailsText:String = ''; - var detailsPausedText:String = ''; - #end + function get_currentChart():SongDifficulty + { + if (currentSong == null || currentDifficulty == null) return null; + return currentSong.getDifficulty(currentDifficulty); + } /** - * This sucks. We need this because FlxG.resetState(); assumes the constructor has no arguments. - * @see https://github.com/HaxeFlixel/flixel/issues/2541 + * The internal ID of the currently active Stage. + * Used to retrieve the data required to build the `currentStage`. */ - static var lastParams:PlayStateParams = null; + public var currentStageId(get, null):String; + function get_currentStageId():String + { + if (currentChart == null || currentChart.stage == null || currentChart.stage == '') return Constants.DEFAULT_STAGE; + return currentChart.stage; + } + + /** + * The length of the current song, in milliseconds. + */ + var currentSongLengthMs(get, never):Float; + + function get_currentSongLengthMs():Float + { + return FlxG?.sound?.music?.length; + } + + // TODO: Refactor or document + var generatedMusic:Bool = false; + var perfectMode:Bool = false; + + /** + * Instantiate a new PlayState. + * @param params The parameters used to initialize the PlayState. + * Includes information about what song to play and more. + */ public function new(params:PlayStateParams) { super(); + // Validate parameters. if (params == null && lastParams == null) { throw 'PlayState constructor called with no available parameters.'; @@ -402,34 +432,43 @@ class PlayState extends MusicBeatState lastParams = params; } + // Apply parameters. currentSong = params.targetSong; if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty; if (params.targetCharacter != null) currentPlayerId = params.targetCharacter; + + // Don't do anything else here! Wait until create() when we attach to the camera. } + /** + * Called when the PlayState is switched to. + */ public override function create():Void { super.create(); if (instance != null) { + // TODO: Do something in this case? IDK. trace('WARNING: PlayState instance already exists. This should not happen.'); } instance = this; if (currentSong != null) { + // Load and cache the song's charts. // TODO: Do this in the loading state. currentSong.cacheCharts(true); } // Returns null if the song failed to load or doesn't have the selected difficulty. - if (currentChart == null) + if (currentSong == null || currentChart == null) { + // We have encountered a critical error. Prevent Flixel from trying to run any gameplay logic. criticalFailure = true; + // Choose an error message. var message:String = 'There was a critical error. Click OK to return to the main menu.'; - if (currentSong == null) { message = 'The was a critical error loading this song\'s chart. Click OK to return to the main menu.'; @@ -443,16 +482,26 @@ class PlayState extends MusicBeatState message = 'The was a critical error retrieving data for this song on "$currentDifficulty" difficulty. Click OK to return to the main menu.'; } + // Display a popup. This blocks the application until the user clicks OK. lime.app.Application.current.window.alert(message, 'Error loading PlayState'); + + // Force the user back to the main menu. FlxG.switchState(new MainMenuState()); return; } - // Displays the camera follow point as a sprite for debug purposes. - // TODO: Put this on a toggle? - cameraFollowPoint.makeGraphic(8, 8, 0xFF00FF00); - cameraFollowPoint.visible = false; - cameraFollowPoint.zIndex = 1000000; + if (false) + { + // Displays the camera follow point as a sprite for debug purposes. + cameraFollowPoint = new FlxSprite(0, 0).makeGraphic(8, 8, 0xFF00FF00); + cameraFollowPoint.visible = false; + cameraFollowPoint.zIndex = 1000000; + } + else + { + // Camera follow point is an invisible point in space. + cameraFollowPoint = new FlxObject(0, 0); + } // Reduce physics accuracy (who cares!!!) to improve animation quality. FlxG.fixedTimestep = false; @@ -465,590 +514,82 @@ class PlayState extends MusicBeatState // Stop any pre-existing music. if (FlxG.sound.music != null) FlxG.sound.music.stop(); - // Prepare the current song to be played. + // Prepare the current song's instrumental and vocals to be played. if (currentChart != null) { - currentChart.cacheInst(); + currentChart.cacheInst(currentPlayerId); currentChart.cacheVocals(currentPlayerId); } - // Initialize stage stuff. - initCameras(); - + // Prepare the Conductor. Conductor.mapTimeChanges(currentChart.timeChanges); - Conductor.update(-5000); - // Once the song is loaded, we can continue and initialize the stage. - - var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9; - healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar')); - healthBarBG.screenCenter(X); - healthBarBG.scrollFactor.set(0, 0); - add(healthBarBG); - - healthBar = new FlxBar(healthBarBG.x + 4, healthBarBG.y + 4, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), this, - 'healthLerp', 0, 2); - healthBar.scrollFactor.set(); - healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN); - add(healthBar); - + // The song is now loaded. We can continue to initialize the play state. + initCameras(); + initHealthBar(); initStage(); initCharacters(); - #if discord_rpc - initDiscord(); - #end - - // Configure camera follow point. - if (previousCameraFollowPoint != null) - { - cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y); - previousCameraFollowPoint = null; - } - add(cameraFollowPoint); + initStrumlines(); + // Initialize the judgements and combo meter. comboPopUps = new PopUpStuff(); comboPopUps.cameras = [camHUD]; add(comboPopUps); - buildStrumlines(); - - grpNoteSplashes = new FlxTypedGroup(); - - var noteSplash:NoteSplash = new NoteSplash(100, 100, 0); - grpNoteSplashes.add(noteSplash); - noteSplash.alpha = 0.1; - - add(grpNoteSplashes); - - generateSong(); - - resetCamera(); - - FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height); - - scoreText = new FlxText(healthBarBG.x + healthBarBG.width - 190, healthBarBG.y + 30, 0, '', 20); - scoreText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); - scoreText.scrollFactor.set(); - add(scoreText); - - // Skip Video Cutscene + // The little dial that shows up when you hold the Skip Cutscene key. skipTimer = new FlxPieDial(16, 16, 32, FlxColor.WHITE, 36, CIRCLE, true, 24); skipTimer.amount = 0; skipTimer.zIndex = 1000; + add(skipTimer); // Renders only in video cutscene mode. skipTimer.cameras = [camCutscene]; - add(skipTimer); - // Attach the groups to the HUD camera so they are rendered independent of the stage. - grpNoteSplashes.cameras = [camHUD]; - activeNotes.cameras = [camHUD]; - healthBar.cameras = [camHUD]; - healthBarBG.cameras = [camHUD]; - iconP1.cameras = [camHUD]; - iconP2.cameras = [camHUD]; - scoreText.cameras = [camHUD]; - leftWatermarkText.cameras = [camHUD]; - rightWatermarkText.cameras = [camHUD]; + #if discord_rpc + // Initialize Discord Rich Presence. + initDiscord(); + #end - // Starting song! + // Read the song's note data and pass it to the strumlines. + generateSong(); + + // Reset the camera's zoom and force it to focus on the camera follow point. + resetCamera(); + + initPreciseInputs(); + + FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height); + + // The song is loaded and in the process of starting. + // This gets set back to false when the chart actually starts. startingSong = true; - // TODO: Softcode cutscenes. - // TODO: Alternatively: make a song script that allows startCountdown to be called, - // then cancels the countdown, hides the UI, plays the cutscene, - // then calls PlayState.startCountdown later? - if (currentSong != null) + // TODO: We hardcoded the transition into Winter Horrorland. Do this with a ScriptedSong instead. + if ((currentSong?.songId ?? '').toLowerCase() == 'winter-horrorland') { - switch (currentSong.songId.toLowerCase()) - { - case 'winter-horrorland': - VanillaCutscenes.playHorrorStartCutscene(); - // This one is softcoded now WOOOO! - // case 'senpai' | 'roses' | 'thorns': - // schoolIntro(doof); - // case 'ugh': - // VanillaCutscenes.playUghCutscene(); - // case 'stress': - // VanillaCutscenes.playStressCutscene(); - // case 'guns': - // VanillaCutscenes.playGunsCutscene(); - default: - // VanillaCutscenes will call startCountdown later. - startCountdown(); - } + // VanillaCutscenes will call startCountdown later. + VanillaCutscenes.playHorrorStartCutscene(); } else { + // Call a script event to start the countdown. + // Songs with cutscenes should call event.cancel(). + // As long as they call `PlayState.instance.startCountdown()` later, the countdown will start. startCountdown(); } - #if debug - this.rightWatermarkText.text = Constants.VERSION; - #end + leftWatermarkText.cameras = [camHUD]; + rightWatermarkText.cameras = [camHUD]; + // Initialize some debug stuff. #if debug + // Display the version number (and git commit hash) in the bottom right corner. + this.rightWatermarkText.text = Constants.VERSION; + FlxG.console.registerObject('playState', this); #end } - function get_currentChart():SongDifficulty - { - if (currentSong == null || currentDifficulty == null) return null; - return currentSong.getDifficulty(currentDifficulty); - } - - function get_currentStageId():String - { - if (currentChart == null || currentChart.stage == null || currentChart.stage == '') return Constants.DEFAULT_STAGE; - return currentChart.stage; - } - - /** - * Initializes the game and HUD cameras. - */ - function initCameras():Void - { - // Set the camera zoom. This gets overridden by the value in the stage data. - // defaultCameraZoom = FlxCamera.defaultZoom * 1.05; - - camGame = new SwagCamera(); - camHUD = new FlxCamera(); - camHUD.bgColor.alpha = 0; - camCutscene = new FlxCamera(); - camCutscene.bgColor.alpha = 0; - - FlxG.cameras.reset(camGame); - FlxG.cameras.add(camHUD, false); - FlxG.cameras.add(camCutscene, false); - } - - function initStage():Void - { - if (currentSong != null) - { - if (currentChart == null) - { - trace('Song difficulty could not be loaded.'); - } - - loadStage(currentStageId); - } - else - { - // Fallback. - loadStage('mainStage'); - } - } - - function initCharacters():Void - { - if (currentSong == null || currentChart == null) - { - trace('Song difficulty could not be loaded.'); - } - - // TODO: Switch playable character by manipulating this value. - // TODO: How to choose which one to use for story mode? - - var playableChars:Array = currentChart.getPlayableChars(); - - if (playableChars.length == 0) - { - trace('WARNING: No playable characters found for this song.'); - } - else if (playableChars.indexOf(currentPlayerId) == -1) - { - currentPlayerId = playableChars[0]; - } - - var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayerId); - - // - // GIRLFRIEND - // - var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend); - - if (girlfriend != null) - { - girlfriend.characterType = CharacterType.GF; - } - else if (currentCharData.girlfriend != '') - { - trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...'); - } - else - { - // Chosen GF was '' so we don't load one. - } - - // - // DAD - // - var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent); - - if (dad != null) - { - dad.characterType = CharacterType.DAD; - } - - // - // OPPONENT HEALTH ICON - // - iconP2 = new HealthIcon(currentCharData.opponent, 1); - iconP2.y = healthBar.y - (iconP2.height / 2); - dad.initHealthIcon(true); - add(iconP2); - - // - // BOYFRIEND - // - var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayerId); - - if (boyfriend != null) - { - boyfriend.characterType = CharacterType.BF; - } - - // - // PLAYER HEALTH ICON - // - iconP1 = new HealthIcon(currentPlayerId, 0); - iconP1.y = healthBar.y - (iconP1.height / 2); - boyfriend.initHealthIcon(false); - add(iconP1); - - // - // ADD CHARACTERS TO SCENE - // - - if (currentStage != null) - { - // Characters get added to the stage, not the main scene. - if (girlfriend != null) - { - currentStage.addCharacter(girlfriend, GF); - - #if debug - FlxG.console.registerObject('gf', girlfriend); - #end - } - - if (boyfriend != null) - { - currentStage.addCharacter(boyfriend, BF); - - #if debug - FlxG.console.registerObject('bf', boyfriend); - #end - } - - if (dad != null) - { - currentStage.addCharacter(dad, DAD); - // Camera starts at dad. - cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y); - - #if debug - FlxG.console.registerObject('dad', dad); - #end - } - - // Rearrange by z-indexes. - currentStage.refresh(); - } - } - - /** - * Removes any references to the current stage, then clears the stage cache, - * then reloads all the stages. - * - * This is useful for when you want to edit a stage without reloading the whole game. - * Reloading works on both the JSON and the HXC, if applicable. - * - * Call this by pressing F5 on a debug build. - */ - override function debug_refreshModules():Void - { - // Remove the current stage. If the stage gets deleted while it's still in use, - // it'll probably crash the game or something. - if (this.currentStage != null) - { - remove(currentStage); - var event:ScriptEvent = new ScriptEvent(ScriptEvent.DESTROY, false); - ScriptEventDispatcher.callEvent(currentStage, event); - currentStage = null; - } - - // Stop the vocals. - if (vocals != null) - { - vocals.stop(); - } - - super.debug_refreshModules(); - - var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false); - ScriptEventDispatcher.callEvent(currentSong, event); - } - - /** - * Pauses music and vocals easily. - */ - public function pauseMusic():Void - { - FlxG.sound.music.pause(); - vocals.pause(); - } - - /** - * Loads stage data from cache, assembles the props, - * and adds it to the state. - * @param id - */ - function loadStage(id:String):Void - { - currentStage = StageDataParser.fetchStage(id); - - if (currentStage != null) - { - // Actually create and position the sprites. - var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false); - ScriptEventDispatcher.callEvent(currentStage, event); - - // Apply camera zoom level from stage data. - defaultCameraZoom = currentStage.camZoom; - - // Add the stage to the scene. - this.add(currentStage); - - #if debug - FlxG.console.registerObject('stage', currentStage); - #end - } - else - { - // lolol - lime.app.Application.current.window.alert('Nice job, you ignoramus. $id isn\'t a real stage.\nI\'m falling back to the default so the game doesn\'t shit itself.', - 'Stage Error'); - } - } - - function initDiscord():Void - { - #if discord_rpc - storyDifficultyText = difficultyString(); - iconRPC = currentSong.player2; - - // To avoid having duplicate images in Discord assets - switch (iconRPC) - { - case 'senpai-angry': - iconRPC = 'senpai'; - case 'monster-christmas': - iconRPC = 'monster'; - case 'mom-car': - iconRPC = 'mom'; - } - - // String that contains the mode defined here so it isn't necessary to call changePresence for each mode - detailsText = isStoryMode ? 'Story Mode: Week $storyWeek' : 'Freeplay'; - detailsPausedText = 'Paused - $detailsText'; - - // Updating Discord Rich Presence. - DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC); - #end - } - - function startSong():Void - { - dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START)); - - startingSong = false; - - previousFrameTime = FlxG.game.ticks; - - if (!isGamePaused && currentChart != null) - { - currentChart.playInst(1.0, false); - } - - FlxG.sound.music.onComplete = endSong; - trace('Playing vocals...'); - add(vocals); - vocals.play(); - - #if discord_rpc - // Song duration in a float, useful for the time left feature - songLength = FlxG.sound.music.length; - - // Updating Discord Rich Presence (with Time Left) - DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, songLength); - #end - } - - function generateSong():Void - { - if (currentChart == null) - { - trace('Song difficulty could not be loaded.'); - } - - Conductor.forceBPM(currentChart.getStartingBPM()); - - vocals = currentChart.buildVocals(currentPlayerId); - if (vocals.members.length == 0) - { - trace('WARNING: No vocals found for this song.'); - } - - // Create the rendered note group. - activeNotes = new FlxTypedGroup(); - activeNotes.zIndex = 1000; - add(activeNotes); - - regenNoteData(); - - generatedMusic = true; - } - - function regenNoteData():Void - { - Highscore.tallies.combo = 0; - Highscore.tallies = new Tallies(); - - // Reset song events. - songEvents = currentChart.getEvents(); - SongEventParser.resetEvents(songEvents); - - // Destroy inactive notes. - inactiveNotes = []; - - // Destroy active notes. - activeNotes.forEach(function(nt) { - nt.followsTime = false; - FlxTween.tween(nt, {y: FlxG.height + nt.y}, 0.5, - { - ease: FlxEase.expoIn, - onComplete: function(twn) { - nt.kill(); - activeNotes.remove(nt, true); - nt.destroy(); - } - }); - }); - - var noteData:Array = currentChart.notes; - - var oldNote:Note = null; - for (songNote in noteData) - { - var mustHitNote:Bool = songNote.getMustHitNote(); - - // TODO: Put this in the chart or something? - var strumlineStyle:StrumlineStyle = null; - switch (currentStageId) - { - case 'school': - strumlineStyle = PIXEL; - case 'schoolEvil': - strumlineStyle = PIXEL; - default: - strumlineStyle = NORMAL; - } - - var newNote:Note = new Note(songNote.time, songNote.data, oldNote, false, strumlineStyle); - newNote.mustPress = mustHitNote; - newNote.data.sustainLength = songNote.length; - newNote.data.noteKind = songNote.kind; - newNote.scrollFactor.set(0, 0); - - // Note positioning. - // TODO: Make this more robust. - if (newNote.mustPress) - { - newNote.alignToSturmlineArrow(playerStrumline.getArrow(songNote.getDirection())); - } - else - { - newNote.alignToSturmlineArrow(enemyStrumline.getArrow(songNote.getDirection())); - } - - inactiveNotes.push(newNote); - - oldNote = newNote; - - // Generate X sustain notes. - var sustainSections = Math.round(songNote.length / Conductor.stepLengthMs); - for (noteIndex in 0...sustainSections) - { - var noteTimeOffset:Float = Conductor.stepLengthMs + (Conductor.stepLengthMs * noteIndex); - var sustainNote:Note = new Note(songNote.time + noteTimeOffset, songNote.data, oldNote, true, strumlineStyle); - sustainNote.mustPress = mustHitNote; - sustainNote.data.noteKind = songNote.kind; - sustainNote.scrollFactor.set(0, 0); - - if (sustainNote.mustPress) - { - // Align with the strumline arrow. - sustainNote.alignToSturmlineArrow(playerStrumline.getArrow(songNote.getDirection())); - } - else - { - sustainNote.alignToSturmlineArrow(enemyStrumline.getArrow(songNote.getDirection())); - } - - inactiveNotes.push(sustainNote); - - oldNote = sustainNote; - } - } - - // Sorting is an expensive operation. - // TODO: Make this more efficient. - // DO NOT assume it was done in the chart file. Notes created artificially by sustains are in here too. - inactiveNotes.sort(function(a:Note, b:Note):Int { - return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b); - }); - /** - **/ - } - - #if discord_rpc - override public function onFocus():Void - { - if (health > 0 && !paused && FlxG.autoPause) - { - if (Conductor.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC, true, - songLength - Conductor.songPosition); - else - DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC); - } - - super.onFocus(); - } - - override public function onFocusLost():Void - { - if (health > 0 && !paused && FlxG.autoPause) DiscordClient.changePresence(detailsPausedText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC); - - super.onFocusLost(); - } - #end - - function resyncVocals():Void - { - if (_exiting || vocals == null) return; - - vocals.pause(); - - FlxG.sound.music.play(); - Conductor.update(); - - vocals.time = FlxG.sound.music.time; - vocals.play(); - } - public override function update(elapsed:Float):Void { if (criticalFailure) return; @@ -1130,13 +671,9 @@ class PlayState extends MusicBeatState if (!isGamePaused) { - songTime += FlxG.game.ticks - previousFrameTime; - previousFrameTime = FlxG.game.ticks; - // Interpolation type beat if (Conductor.lastSongPos != Conductor.songPosition) { - songTime = (songTime + Conductor.songPosition) / 2; Conductor.lastSongPos = Conductor.songPosition; } } @@ -1216,22 +753,7 @@ class PlayState extends MusicBeatState } FlxG.watch.addQuick('songPos', Conductor.songPosition); - // Handle GF dance speed. - // TODO: Add a song event for this. - if (currentSong.songId == 'fresh') - { - switch (Conductor.currentBeat) - { - case 16: - gfSpeed = 2; - case 48: - gfSpeed = 1; - case 80: - gfSpeed = 2; - case 112: - gfSpeed = 1; - } - } + // TODO: Add a song event for Handle GF dance speed. // Handle player death. if (!isInCutscene && !disableKeys && !_exiting) @@ -1287,128 +809,8 @@ class PlayState extends MusicBeatState } } - // Iterate over inactive notes. - while (inactiveNotes[0] != null && inactiveNotes[0].data.strumTime - Conductor.songPosition < 1800 / currentChart.scrollSpeed) - { - var dunceNote:Note = inactiveNotes[0]; - - if (dunceNote.mustPress && !dunceNote.isSustainNote) Highscore.tallies.totalNotes++; - - activeNotes.add(dunceNote); - - inactiveNotes.shift(); - } - - // Iterate over active notes. - if (generatedMusic && playerStrumline != null) - { - activeNotes.forEachAlive(function(daNote:Note) { - if ((PreferencesMenu.getPref('downscroll') && daNote.y < -daNote.height) - || (!PreferencesMenu.getPref('downscroll') && daNote.y > FlxG.height)) - { - daNote.active = false; - daNote.visible = false; - } - else - { - daNote.visible = true; - daNote.active = true; - } - - var strumLineMid:Float = playerStrumline.y + Note.swagWidth / 2; - - if (daNote.followsTime) - { - daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(currentChart.scrollSpeed, 2) * daNote.noteSpeedMulti); - } - - if (PreferencesMenu.getPref('downscroll')) - { - daNote.y += playerStrumline.y; - if (daNote.isSustainNote) - { - if (daNote.animation.curAnim.name.endsWith('end') && daNote.prevNote != null) - { - daNote.y += daNote.prevNote.height; - } - else - { - daNote.y += daNote.height / 2; - } - - if ((!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit))) - && daNote.y - daNote.offset.y * daNote.scale.y + daNote.height >= strumLineMid) - { - applyClipRect(daNote); - } - } - } - else - { - if (daNote.followsTime) daNote.y = playerStrumline.y - daNote.y; - if (daNote.isSustainNote - && (!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit))) - && daNote.y + daNote.offset.y * daNote.scale.y <= strumLineMid) - { - applyClipRect(daNote); - } - } - - if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate) - { - var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, Highscore.tallies.combo, true); - dispatchEvent(event); - - // Calling event.cancelEvent() in a module should force the CPU to miss the note. - // This is useful for cool shit, including but not limited to: - // - Making the AI ignore notes which are hazardous. - // - Making the AI miss notes on purpose for aesthetic reasons. - if (event.eventCanceled) - { - daNote.tooLate = true; - } - } - - // WIP interpolation shit? Need to fix the pause issue - // daNote.y = (strumLine.y - (songTime - daNote.strumTime) * (0.45 * SONG.speed[.curDiff])); - - // removing this so whether the note misses or not is entirely up to Note class - // var noteMiss:Bool = daNote.y < -daNote.height; - - // if (PreferencesMenu.getPref('downscroll')) - // noteMiss = daNote.y > FlxG.height; - - if (daNote.isSustainNote && daNote.wasGoodHit) - { - if ((!PreferencesMenu.getPref('downscroll') && daNote.y < -daNote.height) - || (PreferencesMenu.getPref('downscroll') && daNote.y > FlxG.height)) - { - daNote.active = false; - daNote.visible = false; - - daNote.kill(); - activeNotes.remove(daNote, true); - daNote.destroy(); - } - } - if (daNote.wasGoodHit) - { - daNote.active = false; - daNote.visible = false; - - daNote.kill(); - activeNotes.remove(daNote, true); - daNote.destroy(); - } - - if (daNote.tooLate) - { - noteMiss(daNote); - } - }); - } - // Query and activate song events. + // TODO: Check that these work even when songPosition is less than 0. if (songEvents != null && songEvents.length > 0) { var songEventsToActivate:Array = SongEventParser.queryEvents(songEvents, Conductor.songPosition); @@ -1430,16 +832,1408 @@ class PlayState extends MusicBeatState } // Handle keybinds. - if (!isInCutscene && !disableKeys) keyShit(true); + // if (!isInCutscene && !disableKeys) keyShit(true); + processInputQueue(); if (!isInCutscene && !disableKeys) debugKeyShit(); if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed); + // Moving notes into position is now done by Strumline.update(). + processNotes(); + // Dispatch the onUpdate event to scripted elements. dispatchEvent(new UpdateScriptEvent(elapsed)); } - static final CUTSCENE_KEYS:Array = [SPACE, ESCAPE, ENTER]; + public override function dispatchEvent(event:ScriptEvent):Void + { + // ORDER: Module, Stage, Character, Song, Conversation, Note + // Modules should get the first chance to cancel the event. + // super.dispatchEvent(event) dispatches event to module scripts. + super.dispatchEvent(event); + + // Dispatch event to stage script. + ScriptEventDispatcher.callEvent(currentStage, event); + + // Dispatch event to character script(s). + if (currentStage != null) currentStage.dispatchToCharacters(event); + + // Dispatch event to song script. + ScriptEventDispatcher.callEvent(currentSong, event); + + // Dispatch event to conversation script. + ScriptEventDispatcher.callEvent(currentConversation, event); + + // TODO: Dispatch event to note scripts + } + + /** + * Function called before opening a new substate. + * @param subState The substate to open. + */ + public override function openSubState(subState:FlxSubState):Void + { + // If there is a substate which requires the game to continue, + // then make this a condition. + var shouldPause = true; + + if (shouldPause) + { + // Pause the music. + if (FlxG.sound.music != null) + { + FlxG.sound.music.pause(); + if (vocals != null) vocals.pause(); + } + + // Pause the countdown. + Countdown.pauseCountdown(); + } + + super.openSubState(subState); + } + + /** + * Function called before closing the current substate. + * @param subState + */ + public override function closeSubState():Void + { + if (isGamePaused) + { + var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true); + + dispatchEvent(event); + + if (event.eventCanceled) return; + + if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals(); + + // Resume the countdown. + Countdown.resumeCountdown(); + + #if discord_rpc + if (startTimer.finished) + { + DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, + currentSongLengthMs - Conductor.songPosition); + } + else + { + DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC); + } + #end + } + + super.closeSubState(); + } + + #if discord_rpc + /** + * Function called when the game window gains focus. + */ + public override function onFocus():Void + { + if (health > 0 && !paused && FlxG.autoPause) + { + if (Conductor.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song + + ' (' + + storyDifficultyText + + ')', iconRPC, true, + currentSongLengthMs + - Conductor.songPosition); + else + DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC); + } + + super.onFocus(); + } + + /** + * Function called when the game window loses focus. + */ + public override function onFocusLost():Void + { + if (health > 0 && !paused && FlxG.autoPause) DiscordClient.changePresence(detailsPausedText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC); + + super.onFocusLost(); + } + #end + + /** + * This function is called whenever Flixel switches switching to a new FlxState. + * @return Whether to actually switch to the new state. + */ + override function switchTo(nextState:FlxState):Bool + { + var result:Bool = super.switchTo(nextState); + + if (result) + { + performCleanup(); + } + + return result; + } + + /** + * Removes any references to the current stage, then clears the stage cache, + * then reloads all the stages. + * + * This is useful for when you want to edit a stage without reloading the whole game. + * Reloading works on both the JSON and the HXC, if applicable. + * + * Call this by pressing F5 on a debug build. + */ + override function debug_refreshModules():Void + { + // Remove the current stage. If the stage gets deleted while it's still in use, + // it'll probably crash the game or something. + if (this.currentStage != null) + { + remove(currentStage); + var event:ScriptEvent = new ScriptEvent(ScriptEvent.DESTROY, false); + ScriptEventDispatcher.callEvent(currentStage, event); + currentStage = null; + } + + // Stop the vocals. + if (vocals != null) + { + vocals.stop(); + } + + super.debug_refreshModules(); + + var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false); + ScriptEventDispatcher.callEvent(currentSong, event); + } + + override function stepHit():Bool + { + // super.stepHit() returns false if a module cancelled the event. + if (!super.stepHit()) return false; + + if (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200 + || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200) + { + trace("VOCALS NEED RESYNC"); + if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)); + trace(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)); + resyncVocals(); + } + + if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.currentStep)); + if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.currentStep)); + + return true; + } + + override function beatHit():Bool + { + // super.beatHit() returns false if a module cancelled the event. + if (!super.beatHit()) return false; + + if (generatedMusic) + { + // TODO: Sort more efficiently, or less often, to improve performance. + // activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING); + } + + // Only zoom camera if we are zoomed by less than 35%. + if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.currentBeat % cameraZoomRate == 0) + { + // Zoom camera in (1.5%) + FlxG.camera.zoom += cameraZoomIntensity * defaultCameraZoom; + // Hud zooms double (3%) + camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom; + } + // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.currentBeat} % ${cameraZoomRate} == ${Conductor.currentBeat % cameraZoomRate}}'); + + // That combo milestones that got spoiled that one time. + // Comes with NEAT visual and audio effects. + + // bruh this var is bonkers i thot it was a function lmfaooo + + // Break up into individual lines to aid debugging. + + var shouldShowComboText:Bool = false; + // TODO: Re-enable combo text (how to do this without sections?). + // if (currentSong != null) + // { + // shouldShowComboText = (Conductor.currentBeat % 8 == 7); + // var daSection = .getSong()[Std.int(Conductor.currentBeat / 16)]; + // shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection); + // shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5); + // + // var daNextSection = .getSong()[Std.int(Conductor.currentBeat / 16) + 1]; + // var isEndOfSong = .getSong().length < Std.int(Conductor.currentBeat / 16); + // shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection)); + // } + + if (shouldShowComboText) + { + var animShit:ComboMilestone = new ComboMilestone(-100, 300, Highscore.tallies.combo); + animShit.scrollFactor.set(0.6, 0.6); + animShit.cameras = [camHUD]; + add(animShit); + + var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation + + new FlxTimer().start(((Conductor.beatLengthMs / 1000) * 1.25) - frameShit, function(tmr) { + animShit.forceFinish(); + }); + } + + if (playerStrumline != null) playerStrumline.onBeatHit(); + if (opponentStrumline != null) opponentStrumline.onBeatHit(); + + // Make the characters dance on the beat + danceOnBeat(); + + return true; + } + + override function destroy():Void + { + if (currentConversation != null) + { + remove(currentConversation); + currentConversation.kill(); + } + + super.destroy(); + } + + /** + * Handles characters dancing to the beat of the current song. + * + * TODO: Move some of this logic into `Bopper.hx`, or individual character scripts. + */ + function danceOnBeat():Void + { + if (currentStage == null) return; + + // TODO: Add HEY! song events to Tutorial. + if (Conductor.currentBeat % 16 == 15 + && currentStage.getDad().characterId == 'gf' + && Conductor.currentBeat > 16 + && Conductor.currentBeat < 48) + { + currentStage.getBoyfriend().playAnimation('hey', true); + currentStage.getDad().playAnimation('cheer', true); + } + } + + /** + * Initializes the game and HUD cameras. + */ + function initCameras():Void + { + camGame = new SwagCamera(); + camHUD = new FlxCamera(); + camHUD.bgColor.alpha = 0; // Show the game scene behind the camera. + camCutscene = new FlxCamera(); + camCutscene.bgColor.alpha = 0; // Show the game scene behind the camera. + + FlxG.cameras.reset(camGame); + FlxG.cameras.add(camHUD, false); + FlxG.cameras.add(camCutscene, false); + + // Configure camera follow point. + if (previousCameraFollowPoint != null) + { + cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y); + previousCameraFollowPoint = null; + } + add(cameraFollowPoint); + } + + /** + * Initializes the health bar on the HUD. + */ + function initHealthBar():Void + { + var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9; + healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar')); + healthBarBG.screenCenter(X); + healthBarBG.scrollFactor.set(0, 0); + add(healthBarBG); + + healthBar = new FlxBar(healthBarBG.x + 4, healthBarBG.y + 4, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), this, + 'healthLerp', 0, 2); + healthBar.scrollFactor.set(); + healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN); + add(healthBar); + + // The score text below the health bar. + scoreText = new FlxText(healthBarBG.x + healthBarBG.width - 190, healthBarBG.y + 30, 0, '', 20); + scoreText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + scoreText.scrollFactor.set(); + add(scoreText); + + // Move the health bar to the HUD camera. + healthBar.cameras = [camHUD]; + healthBarBG.cameras = [camHUD]; + scoreText.cameras = [camHUD]; + } + + /** + * Generates the stage and all its props. + */ + function initStage():Void + { + loadStage(currentStageId); + } + + /** + * Loads stage data from cache, assembles the props, + * and adds it to the state. + * @param id + */ + function loadStage(id:String):Void + { + currentStage = StageDataParser.fetchStage(id); + + if (currentStage != null) + { + // Actually create and position the sprites. + var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false); + ScriptEventDispatcher.callEvent(currentStage, event); + + // Apply camera zoom level from stage data. + defaultCameraZoom = currentStage.camZoom; + + // Add the stage to the scene. + this.add(currentStage); + + #if debug + FlxG.console.registerObject('stage', currentStage); + #end + } + else + { + // lolol + lime.app.Application.current.window.alert('Nice job, you ignoramus. $id isn\'t a real stage.\nI\'m falling back to the default so the game doesn\'t shit itself.', + 'Stage Error'); + } + } + + /** + * Generates the character sprites and adds them to the stage. + */ + function initCharacters():Void + { + if (currentSong == null || currentChart == null) + { + trace('Song difficulty could not be loaded.'); + } + + // Switch the character we are playing as by manipulating currentPlayerId. + // TODO: How to choose which one to use for story mode? + var playableChars:Array = currentChart.getPlayableChars(); + + if (playableChars.length == 0) + { + trace('WARNING: No playable characters found for this song.'); + } + else if (playableChars.indexOf(currentPlayerId) == -1) + { + currentPlayerId = playableChars[0]; + } + + // + var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayerId); + + // + // GIRLFRIEND + // + var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend); + + if (girlfriend != null) + { + girlfriend.characterType = CharacterType.GF; + } + else if (currentCharData.girlfriend != '') + { + trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...'); + } + else + { + // Chosen GF was '' so we don't load one. + } + + // + // DAD + // + var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent); + + if (dad != null) + { + dad.characterType = CharacterType.DAD; + } + + // + // OPPONENT HEALTH ICON + // + iconP2 = new HealthIcon(currentCharData.opponent, 1); + iconP2.y = healthBar.y - (iconP2.height / 2); + dad.initHealthIcon(true); + add(iconP2); + iconP2.cameras = [camHUD]; + + // + // BOYFRIEND + // + var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayerId); + + if (boyfriend != null) + { + boyfriend.characterType = CharacterType.BF; + } + + // + // PLAYER HEALTH ICON + // + iconP1 = new HealthIcon(currentPlayerId, 0); + iconP1.y = healthBar.y - (iconP1.height / 2); + boyfriend.initHealthIcon(false); + add(iconP1); + iconP1.cameras = [camHUD]; + + // + // ADD CHARACTERS TO SCENE + // + + if (currentStage != null) + { + // Characters get added to the stage, not the main scene. + if (girlfriend != null) + { + currentStage.addCharacter(girlfriend, GF); + + #if debug + FlxG.console.registerObject('gf', girlfriend); + #end + } + + if (boyfriend != null) + { + currentStage.addCharacter(boyfriend, BF); + + #if debug + FlxG.console.registerObject('bf', boyfriend); + #end + } + + if (dad != null) + { + currentStage.addCharacter(dad, DAD); + // Camera starts at dad. + cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y); + + #if debug + FlxG.console.registerObject('dad', dad); + #end + } + + // Rearrange by z-indexes. + currentStage.refresh(); + } + } + + /** + * Constructs the strumlines for each player. + */ + function initStrumlines():Void + { + // var strumlineStyle:StrumlineStyle = NORMAL; + // + //// TODO: Put this in the chart or something? + // switch (currentStageId) + // { + // case 'school': + // strumlineStyle = PIXEL; + // case 'schoolEvil': + // strumlineStyle = PIXEL; + // } + + playerStrumline = new Strumline(true); + opponentStrumline = new Strumline(false); + add(playerStrumline); + add(opponentStrumline); + + // Position the player strumline on the right + playerStrumline.x = FlxG.width - playerStrumline.width - Constants.STRUMLINE_X_OFFSET; + playerStrumline.y = PreferencesMenu.getPref('downscroll') ? FlxG.height - playerStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET; + playerStrumline.zIndex = 200; + playerStrumline.cameras = [camHUD]; + + // Position the opponent strumline on the left + opponentStrumline.x = Constants.STRUMLINE_X_OFFSET; + opponentStrumline.y = PreferencesMenu.getPref('downscroll') ? FlxG.height - opponentStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET; + opponentStrumline.zIndex = 100; + opponentStrumline.cameras = [camHUD]; + + if (!PlayStatePlaylist.isStoryMode) + { + playerStrumline.fadeInArrows(); + } + + if (!PlayStatePlaylist.isStoryMode) + { + opponentStrumline.fadeInArrows(); + } + + this.refresh(); + } + + /** + * Initializes the Discord Rich Presence. + */ + function initDiscord():Void + { + #if discord_rpc + storyDifficultyText = difficultyString(); + iconRPC = currentSong.player2; + + // To avoid having duplicate images in Discord assets + switch (iconRPC) + { + case 'senpai-angry': + iconRPC = 'senpai'; + case 'monster-christmas': + iconRPC = 'monster'; + case 'mom-car': + iconRPC = 'mom'; + } + + // String that contains the mode defined here so it isn't necessary to call changePresence for each mode + detailsText = isStoryMode ? 'Story Mode: Week $storyWeek' : 'Freeplay'; + detailsPausedText = 'Paused - $detailsText'; + + // Updating Discord Rich Presence. + DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC); + #end + } + + function initPreciseInputs():Void + { + FlxG.keys.preventDefaultKeys = []; + PreciseInputManager.instance.onInputPressed.add(onKeyPress); + PreciseInputManager.instance.onInputReleased.add(onKeyRelease); + } + + /** + * Initializes the song (applying the chart, generating the notes, etc.) + * Should be done before the countdown starts. + */ + function generateSong():Void + { + if (currentChart == null) + { + trace('Song difficulty could not be loaded.'); + } + + Conductor.forceBPM(currentChart.getStartingBPM()); + + vocals = currentChart.buildVocals(currentPlayerId); + if (vocals.members.length == 0) + { + trace('WARNING: No vocals found for this song.'); + } + + regenNoteData(); + + generatedMusic = true; + } + + /** + * Read note data from the chart and generate the notes. + */ + function regenNoteData():Void + { + Highscore.tallies.combo = 0; + Highscore.tallies = new Tallies(); + + // Reset song events. + songEvents = currentChart.getEvents(); + SongEventParser.resetEvents(songEvents); + + // TODO: Put this in the chart or something? + // var strumlineStyle:StrumlineStyle = null; + // switch (currentStageId) + // { + // case 'school': + // strumlineStyle = PIXEL; + // case 'schoolEvil': + // strumlineStyle = PIXEL; + // default: + // strumlineStyle = NORMAL; + // } + + // Reset the notes on each strumline. + var noteData:Array = currentChart.notes; + var playerNoteData:Array = []; + var opponentNoteData:Array = []; + + for (songNote in currentChart.notes) + { + var strumTime:Float = songNote.time; + var noteData:Int = songNote.getDirection(); + + var playerNote:Bool = true; + + if (noteData > 3) playerNote = false; + + switch (songNote.getStrumlineIndex()) + { + case 0: + playerNoteData.push(songNote); + case 1: + opponentNoteData.push(songNote); + } + } + + playerStrumline.applyNoteData(playerNoteData); + opponentStrumline.applyNoteData(opponentNoteData); + } + + /** + * Prepares to start the countdown. + * Ends any running cutscenes, creates the strumlines, and starts the countdown. + * This is public so that scripts can call it. + */ + public function startCountdown():Void + { + // If Countdown.performCountdown returns false, then the countdown was canceled by a script. + var result:Bool = Countdown.performCountdown(currentStageId.startsWith('school')); + if (!result) return; + + isInCutscene = false; + camCutscene.visible = false; + camHUD.visible = true; + } + + /** + * Displays a dialogue cutscene with the given ID. + * This is used by song scripts to display dialogue. + */ + public function startConversation(conversationId:String):Void + { + isInCutscene = true; + + currentConversation = ConversationDataParser.fetchConversation(conversationId); + if (currentConversation == null) return; + + currentConversation.completeCallback = onConversationComplete; + currentConversation.cameras = [camCutscene]; + currentConversation.zIndex = 1000; + add(currentConversation); + refresh(); + + var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false); + ScriptEventDispatcher.callEvent(currentConversation, event); + } + + /** + * Handler function called when a conversation ends. + */ + function onConversationComplete():Void + { + isInCutscene = true; + remove(currentConversation); + currentConversation = null; + + if (startingSong && !isInCountdown) + { + startCountdown(); + } + } + + /** + * Starts playing the song after the countdown has completed. + */ + function startSong():Void + { + dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START)); + + startingSong = false; + + if (!isGamePaused && currentChart != null) + { + currentChart.playInst(1.0, false); + } + + FlxG.sound.music.onComplete = endSong; + trace('Playing vocals...'); + add(vocals); + vocals.play(); + + #if discord_rpc + // Updating Discord Rich Presence (with Time Left) + DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, currentSongLengthMs); + #end + } + + /** + * Resyncronize the vocal tracks if they have become offset from the instrumental. + */ + function resyncVocals():Void + { + if (_exiting || vocals == null) return; + + vocals.pause(); + + FlxG.sound.music.play(); + Conductor.update(); + + vocals.time = FlxG.sound.music.time; + vocals.play(); + } + + /** + * Updates the position and contents of the score display. + */ + function updateScoreText():Void + { + // TODO: Add functionality for modules to update the score text. + scoreText.text = 'Score:' + songScore; + } + + /** + * Updates the values of the health bar. + */ + function updateHealthBar():Void + { + healthLerp = FlxMath.lerp(healthLerp, health, 0.15); + } + + /** + * Callback executed when one of the note keys is pressed. + */ + function onKeyPress(event:PreciseInputEvent):Void + { + // Do the minimal possible work here. + inputPressQueue.push(event); + } + + /** + * Callback executed when one of the note keys is released. + */ + function onKeyRelease(event:PreciseInputEvent):Void + { + // Do the minimal possible work here. + inputReleaseQueue.push(event); + } + + /** + * Handles opponent note hits and player note misses. + */ + function processNotes():Void + { + // Process notes on the opponent's side. + for (note in opponentStrumline.notes.members) + { + if (note == null) continue; + + var hitWindowStart = note.strumTime - Conductor.HIT_WINDOW_MS; + var hitWindowCenter = note.strumTime; + var hitWindowEnd = note.strumTime + Conductor.HIT_WINDOW_MS; + + if (Conductor.songPosition > hitWindowEnd) + { + note.tooEarly = false; + note.mayHit = false; + note.tooLate = true; + } + else if (Conductor.songPosition > hitWindowCenter) + { + // Call an event to allow canceling the note hit. + // NOTE: This is what handles the character animations! + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, 0, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Command the opponent to hit the note on time. + // NOTE: This is what handles the strumline and cleaning up the note itself! + opponentStrumline.hitNote(note); + + // scoreNote(); + } + else if (Conductor.songPosition > hitWindowStart) + { + note.tooEarly = false; + note.mayHit = true; + note.tooLate = false; + } + else + { + note.tooEarly = true; + note.mayHit = false; + note.tooLate = false; + } + } + + // Process notes on the player's side. + for (note in playerStrumline.notes.members) + { + if (note == null || note.hasBeenHit) continue; + + // If this is true, the note is already properly off the screen. + if (note.hasMissed) + { + // Call an event to allow canceling the note miss. + // NOTE: This is what handles the character animations! + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, 0, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Judge the miss. + // NOTE: This is what handles the scoring. + onNoteMiss(note); + + // Kill the note. + // NOTE: This is what handles recycling the note graphic. + playerStrumline.killNote(note); + } + } + } + + /** + * Spitting out the input for ravy 🙇‍♂️!! + */ + var inputSpitter:Array = []; + + /** + * PreciseInputEvents are put into a queue between update() calls, + * and then processed here. + */ + function processInputQueue():Void + { + if (inputPressQueue.length + inputReleaseQueue.length == 0) return; + + // Ignore inputs during cutscenes. + if (isInCutscene || disableKeys) + { + inputPressQueue = []; + inputReleaseQueue = []; + return; + } + + // Generate a list of notes within range. + var notesInRange:Array = playerStrumline.getNotesInRange(Conductor.songPosition, Conductor.HIT_WINDOW_MS); + + // If there are notes in range, pressing a key will cause a ghost miss. + // var canMiss:Bool = notesInRange.length > 0; + var canMiss:Bool = true; // Forced to true for consistency with other input systems. + + var notesByDirection:Array> = [[], [], [], []]; + + for (note in notesInRange) + notesByDirection[note.direction].push(note); + + while (inputPressQueue.length > 0) + { + var input:PreciseInputEvent = inputPressQueue.shift(); + + var notesInDirection:Array = notesByDirection[input.noteDirection]; + + if (canMiss && notesInDirection.length == 0) + { + // Pressed a wrong key with notes in range. + // Perform a ghost miss. + ghostNoteMiss(input.noteDirection, notesInRange.length > 0); + + // Play the strumline animation. + playerStrumline.playPress(input.noteDirection); + } + else if (notesInDirection.length > 0) + { + // Choose the first note, deprioritizing low priority notes. + var targetNote:Null = notesInDirection.find((note) -> !note.lowPriority); + if (targetNote == null) targetNote = notesInDirection[0]; + if (targetNote == null) continue; + + // Judge and hit the note. + goodNoteHit(targetNote, input); + + targetNote.visible = false; + targetNote.kill(); + + // Play the strumline animation. + playerStrumline.playConfirm(input.noteDirection); + } + else + { + // Play the strumline animation. + playerStrumline.playPress(input.noteDirection); + } + } + + while (inputReleaseQueue.length > 0) + { + var input:PreciseInputEvent = inputReleaseQueue.shift(); + + // Play the strumline animation. + playerStrumline.playStatic(input.noteDirection); + } + } + + /** + * Handle player inputs. + */ + function keyShit(test:Bool):Void + { + // control arrays, order L D R U + var holdArray:Array = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT]; + var pressArray:Array = [ + controls.NOTE_LEFT_P, + controls.NOTE_DOWN_P, + controls.NOTE_UP_P, + controls.NOTE_RIGHT_P + ]; + var releaseArray:Array = [ + controls.NOTE_LEFT_R, + controls.NOTE_DOWN_R, + controls.NOTE_UP_R, + controls.NOTE_RIGHT_R + ]; + + // if (pressArray.contains(true)) + // { + // var lol:Array = cast pressArray; + // inputSpitter.push(Std.int(Conductor.songPosition) + ' ' + lol.join(' ')); + // } + + // HOLDS, check for sustain notes + if (holdArray.contains(true) && generatedMusic) + { + /* + activeNotes.forEachAlive(function(daNote:Note) { + if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData]) goodNoteHit(daNote); + }); + */ + } + + // PRESSES, check for note hits + if (pressArray.contains(true) && generatedMusic) + { + Haptic.vibrate(100, 100); + + if (currentStage != null && currentStage.getBoyfriend() != null) + { + currentStage.getBoyfriend().holdTimer = 0; + } + + var possibleNotes:Array = []; // notes that can be hit + var directionList:Array = []; // directions that can be hit + var dumbNotes:Array = []; // notes to kill later + + /* + activeNotes.forEachAlive(function(daNote:Note) { + if (daNote.canBeHit && daNote.mustPress && !daNote.tooLate && !daNote.hasBeenHit) + { + if (directionList.contains(daNote.data.noteData)) + { + for (coolNote in possibleNotes) + { + if (coolNote.data.noteData == daNote.data.noteData && Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10) + { // if it's the same note twice at < 10ms distance, just delete it + // EXCEPT u cant delete it in this loop cuz it fucks with the collection lol + dumbNotes.push(daNote); + break; + } + else if (coolNote.data.noteData == daNote.data.noteData && daNote.data.strumTime < coolNote.data.strumTime) + { // if daNote is earlier than existing note (coolNote), replace + possibleNotes.remove(coolNote); + possibleNotes.push(daNote); + break; + } + } + } + else + { + possibleNotes.push(daNote); + directionList.push(daNote.data.noteData); + } + } + }); + */ + + for (note in dumbNotes) + { + FlxG.log.add('killing dumb ass note at ' + note.noteData.time); + note.kill(); + // activeNotes.remove(note, true); + note.destroy(); + } + + possibleNotes.sort((a, b) -> Std.int(a.noteData.time - b.noteData.time)); + + if (perfectMode) + { + goodNoteHit(possibleNotes[0], null); + } + else if (possibleNotes.length > 0) + { + for (shit in 0...pressArray.length) + { // if a direction is hit that shouldn't be + if (pressArray[shit] && !directionList.contains(shit)) ghostNoteMiss(shit); + } + for (coolNote in possibleNotes) + { + if (pressArray[coolNote.noteData.getDirection()]) goodNoteHit(coolNote, null); + } + } + else + { + // HNGGG I really want to add an option for ghost tapping + // L + ratio + for (shit in 0...pressArray.length) + if (pressArray[shit]) ghostNoteMiss(shit, false); + } + } + + if (currentStage == null) return; + + for (keyId => isPressed in pressArray) + { + if (playerStrumline == null) continue; + + var dir:NoteDirection = Strumline.DIRECTIONS[keyId]; + + if (isPressed && !playerStrumline.isConfirm(dir)) playerStrumline.playPress(dir); + if (!holdArray[keyId]) playerStrumline.playStatic(dir); + } + } + + function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void + { + if (!note.hasBeenHit) + { + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) return; + + if (!note.isSustainNote) + { + Highscore.tallies.combo++; + Highscore.tallies.totalNotesHit++; + + if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo; + + popUpScore(note, input); + } + + playerStrumline.playConfirm(note.noteData.getDirection()); + + note.hasBeenHit = true; + vocals.playerVolume = 1; + + if (!note.isSustainNote) + { + note.kill(); + // activeNotes.remove(note, true); + note.destroy(); + } + } + } + + /** + * Called when a note leaves the screen and is considered missed by the player. + * @param note + */ + function onNoteMiss(note:NoteSprite):Void + { + // a MISS is when you let a note scroll past you!! + Highscore.tallies.missed++; + + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, Highscore.tallies.combo, true); + dispatchEvent(event); + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) return; + + health -= 0.0775; + + if (!isPracticeMode) + { + songScore -= 10; + + // messy copy paste rn lol + var pressArray:Array = [ + controls.NOTE_LEFT_P, + controls.NOTE_DOWN_P, + controls.NOTE_UP_P, + controls.NOTE_RIGHT_P + ]; + + var indices:Array = []; + for (i in 0...pressArray.length) + { + if (pressArray[i]) indices.push(i); + } + if (indices.length > 0) + { + for (i in 0...indices.length) + { + inputSpitter.push( + { + t: Std.int(Conductor.songPosition), + d: indices[i], + l: 20 + }); + } + } + else + { + inputSpitter.push( + { + t: Std.int(Conductor.songPosition), + d: -1, + l: 20 + }); + } + } + vocals.playerVolume = 0; + + if (Highscore.tallies.combo != 0) + { + Highscore.tallies.combo = comboPopUps.displayCombo(0); + } + + if (event.playSound) + { + vocals.playerVolume = 0; + FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); + } + + note.active = false; + note.visible = false; + + note.kill(); + // activeNotes.remove(note, true); + note.destroy(); + } + + /** + * Called when a player presses a key with no note present. + * Scripts can modify the amount of health/score lost, whether player animations or sounds are used, + * or even cancel the event entirely. + * + * @param direction + * @param hasPossibleNotes + */ + function ghostNoteMiss(direction:NoteDirection, hasPossibleNotes:Bool = true):Void + { + var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in. + hasPossibleNotes, // Whether there was a note you could have hit. + - 0.035 * 2, // How much health to add (negative). + - 10 // Amount of score to add (negative). + ); + dispatchEvent(event); + + // Calling event.cancelEvent() skips animations and penalties. Neat! + if (event.eventCanceled) return; + + health += event.healthChange; + + if (!isPracticeMode) + { + songScore += event.scoreChange; + + var pressArray:Array = [ + controls.NOTE_LEFT_P, + controls.NOTE_DOWN_P, + controls.NOTE_UP_P, + controls.NOTE_RIGHT_P + ]; + + var indices:Array = []; + for (i in 0...pressArray.length) + { + if (pressArray[i]) indices.push(i); + } + for (i in 0...indices.length) + { + inputSpitter.push( + { + t: Std.int(Conductor.songPosition), + d: indices[i], + l: 20 + }); + } + } + + if (event.playSound) + { + vocals.playerVolume = 0; + FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); + } + } + + /** + * Debug keys. Disabled while in cutscenes. + */ + function debugKeyShit():Void + { + #if !debug + perfectMode = false; + #else + if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible; + #end + + if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState()); + + if (FlxG.keys.justPressed.F5) debug_refreshModules(); + + // Press U to open stage ditor. + if (FlxG.keys.justPressed.U) + { + // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!! + disableKeys = true; + persistentUpdate = false; + openSubState(new StageOffsetSubState()); + } + + #if debug + // 1: End the song immediately. + if (FlxG.keys.justPressed.ONE) endSong(); + + // 2: Gain 10% health. + if (FlxG.keys.justPressed.TWO) health += 0.1 * 2.0; + + // 3: Lose 5% health. + if (FlxG.keys.justPressed.THREE) health -= 0.05 * 2.0; + #end + + // 7: Move to the charter. + if (FlxG.keys.justPressed.SEVEN) + { + lime.app.Application.current.window.alert("Press ~ on the main menu to get to the editor", 'LOL'); + } + + // 8: Move to the offset editor. + if (FlxG.keys.justPressed.EIGHT) FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState()); + + // 9: Toggle the old icon. + if (FlxG.keys.justPressed.NINE) iconP1.toggleOldIcon(); + + #if debug + // PAGEUP: Skip forward one section. + // SHIFT+PAGEUP: Skip forward ten sections. + if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 10 : 1); + // PAGEDOWN: Skip backward one section. Doesn't replace notes. + // SHIFT+PAGEDOWN: Skip backward ten sections. + if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -10 : -1); + #end + + if (FlxG.keys.justPressed.B) trace(inputSpitter.join('\n')); + } + + /** + * Handles health, score, and rating popups when a note is hit. + */ + function popUpScore(daNote:NoteSprite, input:PreciseInputEvent):Void + { + vocals.playerVolume = 1; + + // Calculate the input latency (do this as late as possible). + var inputLatencyMs:Float = haxe.Int64.toInt(PreciseInputManager.getCurrentTimestamp() - input.timestamp) / 1000.0 / 1000.0; + trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!'); + + // Get the offset and compensate for input latency. + // Round inward (trim remainder) for consistency. + var noteDiff:Int = Std.int(Conductor.songPosition - daNote.noteData.time - inputLatencyMs); + + var score = Scoring.scoreNote(noteDiff, PBOT1); + var daRating = Scoring.judgeNote(noteDiff, PBOT1); + + var isSick:Bool = false; + var healthMulti:Float = 0; + + switch (daRating) + { + case 'killer': + Highscore.tallies.killer += 1; + healthMulti = 0.033; + case 'sick': + Highscore.tallies.sick += 1; + healthMulti = 0.033; + case 'good': + Highscore.tallies.good += 1; + healthMulti = 0.033 * 0.78; + case 'bad': + Highscore.tallies.bad += 1; + healthMulti = 0.033 * 0.2; + case 'shit': + Highscore.tallies.shit += 1; + healthMulti = 0; + case 'miss': + Highscore.tallies.missed += 1; + healthMulti = 0; + } + + health += healthMulti; + if (daRating == "sick" || daRating == "killer") + { + playerStrumline.playNoteSplash(daNote.noteData.getDirection()); + } + // Only add the score if you're not on practice mode + if (!isPracticeMode) + { + songScore += score; + + // TODO: Input splitter uses old input system, make it pull from the precise input queue directly. + var pressArray:Array = [ + controls.NOTE_LEFT_P, + controls.NOTE_DOWN_P, + controls.NOTE_UP_P, + controls.NOTE_RIGHT_P + ]; + + var indices:Array = []; + for (i in 0...pressArray.length) + { + if (pressArray[i]) indices.push(i); + } + if (indices.length > 0) + { + for (i in 0...indices.length) + { + inputSpitter.push( + { + t: Std.int(Conductor.songPosition), + d: indices[i], + l: 20 + }); + } + } + else + { + inputSpitter.push( + { + t: Std.int(Conductor.songPosition), + d: -1, + l: 20 + }); + } + } + comboPopUps.displayRating(daRating); + if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0) comboPopUps.displayCombo(Highscore.tallies.combo); + } + + /** + * Handle keyboard inputs during cutscenes. + * This includes advancing conversations and skipping videos. + * @param elapsed Time elapsed since last game update. + */ function handleCutsceneKeys(elapsed:Float):Void { if (currentConversation != null) @@ -1470,7 +2264,12 @@ class PlayState extends MusicBeatState } } - public function trySkipVideoCutscene(elapsed:Float):Void + /** + * Handle logic for the skip timer. + * If the skip button is being held, pass the amount of time elapsed since last game update. + * If the skip button has been released, pass a negative number. + */ + function trySkipVideoCutscene(elapsed:Float):Void { if (skipTimer == null || skipTimer.animation == null) return; @@ -1492,81 +2291,9 @@ class PlayState extends MusicBeatState } } - function applyClipRect(daNote:Note):Void - { - // clipRect is applied to graphic itself so use frame Heights - var swagRect:FlxRect = new FlxRect(0, 0, daNote.frameWidth, daNote.frameHeight); - var strumLineMid:Float = playerStrumline.y + Note.swagWidth / 2; - - if (PreferencesMenu.getPref('downscroll')) - { - swagRect.height = (strumLineMid - daNote.y) / daNote.scale.y; - swagRect.y = daNote.frameHeight - swagRect.height; - } - else - { - swagRect.y = (strumLineMid - daNote.y) / daNote.scale.y; - swagRect.height -= swagRect.y; - } - - daNote.clipRect = swagRect; - } - - function killCombo():Void - { - // Girlfriend gets sad if you combo break after hitting 5 notes. - if (currentStage != null && currentStage.getGirlfriend() != null) - { - if (Highscore.tallies.combo > 5 && currentStage.getGirlfriend().hasAnimation('sad')) - { - currentStage.getGirlfriend().playAnimation('sad'); - } - } - - if (Highscore.tallies.combo != 0) - { - Highscore.tallies.combo = comboPopUps.displayCombo(0); - } - } - - #if debug /** - * Jumps forward or backward a number of sections in the song. - * Accounts for BPM changes, does not prevent death from skipped notes. - * @param sections The number of sections to jump, negative to go backwards. + * End the song. Handle saving high scores and transitioning to the results screen. */ - function changeSection(sections:Int):Void - { - FlxG.sound.music.pause(); - - FlxG.sound.music.time += sections * Conductor.measureLengthMs; - - Conductor.update(FlxG.sound.music.time); - - /** - * - // TODO: Redo this for the new conductor. - var daBPM:Float = Conductor.bpm; - var daPos:Float = 0; - for (i in 0...(Std.int(Conductor.currentStep / 16 + sec))) - { - var section = .getSong()[i]; - if (section == null) continue; - if (section.changeBPM) - { - daBPM = .getSong()[i].bpm; - } - daPos += 4 * (1000 * 60 / daBPM); - } - Conductor.songPosition = FlxG.sound.music.time = daPos; - Conductor.songPosition += Conductor.offset; - - */ - - resyncVocals(); - } - #end - function endSong():Void { dispatchEvent(new ScriptEvent(ScriptEvent.SONG_END)); @@ -1675,6 +2402,31 @@ class PlayState extends MusicBeatState } } + /** + * Perform necessary cleanup before leaving the PlayState. + */ + function performCleanup():Void + { + if (currentChart != null) + { + // TODO: Uncache the song. + } + + // Remove reference to stage and remove sprites from it to save memory. + if (currentStage != null) + { + remove(currentStage); + currentStage.kill(); + dispatchEvent(new ScriptEvent(ScriptEvent.DESTROY, false)); + currentStage = null; + } + + GameOverSubState.reset(); + + // Clear the static reference to this state. + instance = null; + } + /** * Play the camera zoom animation and move to the results screen. */ @@ -1688,24 +2440,24 @@ class PlayState extends MusicBeatState // If the opponent is GF, zoom in on the opponent. // Else, if there is no GF, zoom in on BF. // Else, zoom in on GF. - var targetDad:Bool = PlayState.instance.currentStage.getDad() != null && PlayState.instance.currentStage.getDad().characterId == 'gf'; - var targetBF:Bool = PlayState.instance.currentStage.getGirlfriend() == null && !targetDad; + var targetDad:Bool = currentStage.getDad() != null && currentStage.getDad().characterId == 'gf'; + var targetBF:Bool = currentStage.getGirlfriend() == null && !targetDad; if (targetBF) { - FlxG.camera.follow(PlayState.instance.currentStage.getBoyfriend(), null, 0.05); + FlxG.camera.follow(currentStage.getBoyfriend(), null, 0.05); FlxG.camera.targetOffset.y -= 350; FlxG.camera.targetOffset.x += 20; } else if (targetDad) { - FlxG.camera.follow(PlayState.instance.currentStage.getDad(), null, 0.05); + FlxG.camera.follow(currentStage.getDad(), null, 0.05); FlxG.camera.targetOffset.y -= 350; FlxG.camera.targetOffset.x += 20; } else { - FlxG.camera.follow(PlayState.instance.currentStage.getGirlfriend(), null, 0.05); + FlxG.camera.follow(currentStage.getGirlfriend(), null, 0.05); FlxG.camera.targetOffset.y -= 350; FlxG.camera.targetOffset.x += 20; } @@ -1743,748 +2495,13 @@ class PlayState extends MusicBeatState }); } - // gives score and pops up rating - function popUpScore(strumtime:Float, daNote:Note):Void - { - var noteDiff:Float = Math.abs(strumtime - Conductor.songPosition); - // boyfriend.playAnimation('hey'); - vocals.playerVolume = 1; - - var isSick:Bool = false; - var score = Scoring.scoreNote(noteDiff, PBOT1); - var daRating = Scoring.judgeNote(noteDiff, PBOT1); - var healthMulti:Float = daNote.lowStakes ? 0.002 : 0.033; - - if (noteDiff > Note.HIT_WINDOW * Note.BAD_THRESHOLD) - { - healthMulti *= 0; // no health on shit note - daRating = 'shit'; - Highscore.tallies.shit += 1; - // score = 50; - } - else if (noteDiff > Note.HIT_WINDOW * Note.GOOD_THRESHOLD) - { - healthMulti *= 0.2; - daRating = 'bad'; - Highscore.tallies.bad += 1; - } - else if (noteDiff > Note.HIT_WINDOW * Note.SICK_THRESHOLD) - { - healthMulti *= 0.78; - daRating = 'good'; - Highscore.tallies.good += 1; - // score = 200; - } - else - { - isSick = true; - } - - health += healthMulti; - if (isSick) - { - Highscore.tallies.sick += 1; - var noteSplash:NoteSplash = grpNoteSplashes.recycle(NoteSplash); - noteSplash.setupNoteSplash(daNote.x, daNote.y, daNote.data.noteData); - // new NoteSplash(daNote.x, daNote.y, daNote.noteData); - grpNoteSplashes.add(noteSplash); - } - // Only add the score if you're not on practice mode - if (!isPracticeMode) - { - songScore += score; - - var pressArray:Array = [ - controls.NOTE_LEFT_P, - controls.NOTE_DOWN_P, - controls.NOTE_UP_P, - controls.NOTE_RIGHT_P - ]; - - var indices:Array = []; - for (i in 0...pressArray.length) - { - if (pressArray[i]) indices.push(i); - } - if (indices.length > 0) - { - for (i in 0...indices.length) - { - inputSpitter.push( - { - t: Std.int(Conductor.songPosition), - d: indices[i], - l: 20 - }); - } - } - else - { - inputSpitter.push( - { - t: Std.int(Conductor.songPosition), - d: -1, - l: 20 - }); - } - } - comboPopUps.displayRating(daRating); - if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0) comboPopUps.displayCombo(Highscore.tallies.combo); - } - /** - * Spitting out the input for ravy 🙇‍♂️!! + * Pauses music and vocals easily. */ - var inputSpitter:Array = []; - - public function keyShit(test:Bool):Void + public function pauseMusic():Void { - if (PlayState.instance == null) return; - - // control arrays, order L D R U - var holdArray:Array = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT]; - var pressArray:Array = [ - controls.NOTE_LEFT_P, - controls.NOTE_DOWN_P, - controls.NOTE_UP_P, - controls.NOTE_RIGHT_P - ]; - var releaseArray:Array = [ - controls.NOTE_LEFT_R, - controls.NOTE_DOWN_R, - controls.NOTE_UP_R, - controls.NOTE_RIGHT_R - ]; - - // if (pressArray.contains(true)) - // { - // var lol:Array = cast pressArray; - // inputSpitter.push(Std.int(Conductor.songPosition) + ' ' + lol.join(' ')); - // } - - // HOLDS, check for sustain notes - if (holdArray.contains(true) && PlayState.instance.generatedMusic) - { - PlayState.instance.activeNotes.forEachAlive(function(daNote:Note) { - if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData]) PlayState.instance.goodNoteHit(daNote); - }); - } - - // PRESSES, check for note hits - if (pressArray.contains(true) && PlayState.instance.generatedMusic) - { - Haptic.vibrate(100, 100); - - if (currentStage != null && currentStage.getBoyfriend() != null) - { - currentStage.getBoyfriend().holdTimer = 0; - } - - var possibleNotes:Array = []; // notes that can be hit - var directionList:Array = []; // directions that can be hit - var dumbNotes:Array = []; // notes to kill later - - PlayState.instance.activeNotes.forEachAlive(function(daNote:Note) { - if (daNote.canBeHit && daNote.mustPress && !daNote.tooLate && !daNote.wasGoodHit) - { - if (directionList.contains(daNote.data.noteData)) - { - for (coolNote in possibleNotes) - { - if (coolNote.data.noteData == daNote.data.noteData && Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10) - { // if it's the same note twice at < 10ms distance, just delete it - // EXCEPT u cant delete it in this loop cuz it fucks with the collection lol - dumbNotes.push(daNote); - break; - } - else if (coolNote.data.noteData == daNote.data.noteData && daNote.data.strumTime < coolNote.data.strumTime) - { // if daNote is earlier than existing note (coolNote), replace - possibleNotes.remove(coolNote); - possibleNotes.push(daNote); - break; - } - } - } - else - { - possibleNotes.push(daNote); - directionList.push(daNote.data.noteData); - } - } - }); - - for (note in dumbNotes) - { - FlxG.log.add('killing dumb ass note at ' + note.data.strumTime); - note.kill(); - PlayState.instance.activeNotes.remove(note, true); - note.destroy(); - } - - possibleNotes.sort((a, b) -> Std.int(a.data.strumTime - b.data.strumTime)); - - if (PlayState.instance.perfectMode) PlayState.instance.goodNoteHit(possibleNotes[0]); - else if (possibleNotes.length > 0) - { - for (shit in 0...pressArray.length) - { // if a direction is hit that shouldn't be - if (pressArray[shit] && !directionList.contains(shit)) PlayState.instance.ghostNoteMiss(shit); - } - for (coolNote in possibleNotes) - { - if (pressArray[coolNote.data.noteData]) PlayState.instance.goodNoteHit(coolNote); - } - } - else - { - // HNGGG I really want to add an option for ghost tapping - // L + ratio - for (shit in 0...pressArray.length) - if (pressArray[shit]) PlayState.instance.ghostNoteMiss(shit, false); - } - } - - if (PlayState.instance == null || PlayState.instance.currentStage == null) return; - - for (keyId => isPressed in pressArray) - { - if (playerStrumline == null) continue; - var arrow:StrumlineArrow = PlayState.instance.playerStrumline.getArrow(keyId); - - if (isPressed && arrow.animation.curAnim.name != 'confirm') - { - arrow.playAnimation('pressed'); - } - if (!holdArray[keyId]) - { - arrow.playAnimation('static'); - } - } - } - - /** - * Debug keys. Disabled while in cutscenes. - */ - public function debugKeyShit():Void - { - #if !debug - perfectMode = false; - #else - if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible; - #end - - if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState()); - - if (FlxG.keys.justPressed.F5) debug_refreshModules(); - - // Press U to open stage ditor. - if (FlxG.keys.justPressed.U) - { - // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!! - disableKeys = true; - persistentUpdate = false; - openSubState(new StageOffsetSubState()); - } - - #if debug - // 1: End the song immediately. - if (FlxG.keys.justPressed.ONE) endSong(); - - // 2: Gain 10% health. - if (FlxG.keys.justPressed.TWO) health += 0.1 * 2.0; - - // 3: Lose 5% health. - if (FlxG.keys.justPressed.THREE) health -= 0.05 * 2.0; - #end - - // 7: Move to the charter. - if (FlxG.keys.justPressed.SEVEN) - { - lime.app.Application.current.window.alert("Press ~ on the main menu to get to the editor", 'LOL'); - } - - // 8: Move to the offset editor. - if (FlxG.keys.justPressed.EIGHT) FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState()); - - // 9: Toggle the old icon. - if (FlxG.keys.justPressed.NINE) iconP1.toggleOldIcon(); - - #if debug - // PAGEUP: Skip forward one section. - // SHIFT+PAGEUP: Skip forward ten sections. - if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 10 : 1); - // PAGEDOWN: Skip backward one section. Doesn't replace notes. - // SHIFT+PAGEDOWN: Skip backward ten sections. - if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -10 : -1); - #end - - if (FlxG.keys.justPressed.B) trace(inputSpitter.join('\n')); - } - - /** - * Called when a player presses a key with no note present. - * Scripts can modify the amount of health/score lost, whether player animations or sounds are used, - * or even cancel the event entirely. - * - * @param direction - * @param hasPossibleNotes - */ - function ghostNoteMiss(direction:funkin.noteStuff.NoteBasic.NoteType = 1, hasPossibleNotes:Bool = true):Void - { - var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in. - hasPossibleNotes, // Whether there was a note you could have hit. - - 0.035 * 2, // How much health to add (negative). - - 10 // Amount of score to add (negative). - ); - dispatchEvent(event); - - // Calling event.cancelEvent() skips animations and penalties. Neat! - if (event.eventCanceled) return; - - health += event.healthChange; - - if (!isPracticeMode) - { - songScore += event.scoreChange; - - var pressArray:Array = [ - controls.NOTE_LEFT_P, - controls.NOTE_DOWN_P, - controls.NOTE_UP_P, - controls.NOTE_RIGHT_P - ]; - - var indices:Array = []; - for (i in 0...pressArray.length) - { - if (pressArray[i]) indices.push(i); - } - for (i in 0...indices.length) - { - inputSpitter.push( - { - t: Std.int(Conductor.songPosition), - d: indices[i], - l: 20 - }); - } - } - - if (event.playSound) - { - vocals.playerVolume = 0; - FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); - } - } - - function noteMiss(note:Note):Void - { - // a MISS is when you let a note scroll past you!! - Highscore.tallies.missed++; - - var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, Highscore.tallies.combo, true); - dispatchEvent(event); - // Calling event.cancelEvent() skips all the other logic! Neat! - if (event.eventCanceled) return; - - health -= 0.0775; - - if (!isPracticeMode) - { - songScore -= 10; - - // messy copy paste rn lol - var pressArray:Array = [ - controls.NOTE_LEFT_P, - controls.NOTE_DOWN_P, - controls.NOTE_UP_P, - controls.NOTE_RIGHT_P - ]; - - var indices:Array = []; - for (i in 0...pressArray.length) - { - if (pressArray[i]) indices.push(i); - } - if (indices.length > 0) - { - for (i in 0...indices.length) - { - inputSpitter.push( - { - t: Std.int(Conductor.songPosition), - d: indices[i], - l: 20 - }); - } - } - else - { - inputSpitter.push( - { - t: Std.int(Conductor.songPosition), - d: -1, - l: 20 - }); - } - } - vocals.playerVolume = 0; - - if (Highscore.tallies.combo != 0) - { - Highscore.tallies.combo = comboPopUps.displayCombo(0); - } - - if (event.playSound) - { - vocals.playerVolume = 0; - FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); - } - - note.active = false; - note.visible = false; - - note.kill(); - activeNotes.remove(note, true); - note.destroy(); - } - - function goodNoteHit(note:Note):Void - { - if (!note.wasGoodHit) - { - var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true); - dispatchEvent(event); - - // Calling event.cancelEvent() skips all the other logic! Neat! - if (event.eventCanceled) return; - - if (!note.isSustainNote) - { - Highscore.tallies.combo++; - Highscore.tallies.totalNotesHit++; - - if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo; - - popUpScore(note.data.strumTime, note); - } - - playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true); - - note.wasGoodHit = true; - vocals.playerVolume = 1; - - if (!note.isSustainNote) - { - note.kill(); - activeNotes.remove(note, true); - note.destroy(); - } - } - } - - override function stepHit():Bool - { - // super.stepHit() returns false if a module cancelled the event. - if (!super.stepHit()) return false; - - if (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200 - || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200) - { - trace("VOCALS NEED RESYNC"); - if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)); - trace(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)); - resyncVocals(); - } - - if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.currentStep)); - if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.currentStep)); - - return true; - } - - override function beatHit():Bool - { - // super.beatHit() returns false if a module cancelled the event. - if (!super.beatHit()) return false; - - if (generatedMusic) - { - // TODO: Sort more efficiently, or less often, to improve performance. - activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING); - } - - // Only zoom camera if we are zoomed by less than 35%. - if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.currentBeat % cameraZoomRate == 0) - { - // Zoom camera in (1.5%) - FlxG.camera.zoom += cameraZoomIntensity * defaultCameraZoom; - // Hud zooms double (3%) - camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom; - } - // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.currentBeat} % ${cameraZoomRate} == ${Conductor.currentBeat % cameraZoomRate}}'); - - // That combo milestones that got spoiled that one time. - // Comes with NEAT visual and audio effects. - - // bruh this var is bonkers i thot it was a function lmfaooo - - // Break up into individual lines to aid debugging. - - var shouldShowComboText:Bool = false; - // TODO: Re-enable combo text (how to do this without sections?). - // if (currentSong != null) - // { - // shouldShowComboText = (Conductor.currentBeat % 8 == 7); - // var daSection = .getSong()[Std.int(Conductor.currentBeat / 16)]; - // shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection); - // shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5); - // - // var daNextSection = .getSong()[Std.int(Conductor.currentBeat / 16) + 1]; - // var isEndOfSong = .getSong().length < Std.int(Conductor.currentBeat / 16); - // shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection)); - // } - - if (shouldShowComboText) - { - var animShit:ComboMilestone = new ComboMilestone(-100, 300, Highscore.tallies.combo); - animShit.scrollFactor.set(0.6, 0.6); - animShit.cameras = [camHUD]; - add(animShit); - - var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation - - new FlxTimer().start(((Conductor.beatLengthMs / 1000) * 1.25) - frameShit, function(tmr) { - animShit.forceFinish(); - }); - } - - // Make the characters dance on the beat - danceOnBeat(); - - return true; - } - - /** - * Handles characters dancing to the beat of the current song. - * - * TODO: Move some of this logic into `Bopper.hx` - */ - public function danceOnBeat():Void - { - if (currentStage == null) return; - - // TODO: Add HEY! song events to Tutorial. - if (Conductor.currentBeat % 16 == 15 - && currentStage.getDad().characterId == 'gf' - && Conductor.currentBeat > 16 - && Conductor.currentBeat < 48) - { - currentStage.getBoyfriend().playAnimation('hey', true); - currentStage.getDad().playAnimation('cheer', true); - } - } - - /** - * Constructs the strumlines for each player. - */ - function buildStrumlines():Void - { - var strumlineStyle:StrumlineStyle = NORMAL; - - // TODO: Put this in the chart or something? - switch (currentStageId) - { - case 'school': - strumlineStyle = PIXEL; - case 'schoolEvil': - strumlineStyle = PIXEL; - } - - var strumlineYPos = Strumline.getYPos(); - - playerStrumline = new Strumline(0, strumlineStyle, 4); - playerStrumline.x = 50 + FlxG.width / 2; - playerStrumline.y = strumlineYPos; - // Set the z-index so they don't appear in front of notes. - playerStrumline.zIndex = 100; - add(playerStrumline); - playerStrumline.cameras = [camHUD]; - - if (!PlayStatePlaylist.isStoryMode) - { - playerStrumline.fadeInArrows(); - } - - enemyStrumline = new Strumline(1, strumlineStyle, 4); - enemyStrumline.x = 50; - enemyStrumline.y = strumlineYPos; - // Set the z-index so they don't appear in front of notes. - enemyStrumline.zIndex = 100; - add(enemyStrumline); - enemyStrumline.cameras = [camHUD]; - - if (!PlayStatePlaylist.isStoryMode) - { - enemyStrumline.fadeInArrows(); - } - - this.refresh(); - } - - /** - * Function called before opening a new substate. - * @param subState The substate to open. - */ - public override function openSubState(subState:FlxSubState):Void - { - // If there is a substate which requires the game to continue, - // then make this a condition. - var shouldPause = true; - - if (shouldPause) - { - // Pause the music. - if (FlxG.sound.music != null) - { - FlxG.sound.music.pause(); - if (vocals != null) vocals.pause(); - } - - // Pause the countdown. - Countdown.pauseCountdown(); - } - - super.openSubState(subState); - } - - /** - * Function called before closing the current substate. - * @param subState - */ - public override function closeSubState():Void - { - if (isGamePaused) - { - var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true); - - dispatchEvent(event); - - if (event.eventCanceled) return; - - if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals(); - - // Resume the countdown. - Countdown.resumeCountdown(); - - #if discord_rpc - if (startTimer.finished) - { - DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, songLength - Conductor.songPosition); - } - else - { - DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC); - } - #end - } - - super.closeSubState(); - } - - /** - * Prepares to start the countdown. - * Ends any running cutscenes, creates the strumlines, and starts the countdown. - */ - public function startCountdown():Void - { - // If Countdown.performCountdown returns false, then the countdown was canceled by a script. - var result:Bool = Countdown.performCountdown(currentStageId.startsWith('school')); - if (!result) return; - - isInCutscene = false; - camCutscene.visible = false; - camHUD.visible = true; - } - - public override function dispatchEvent(event:ScriptEvent):Void - { - // ORDER: Module, Stage, Character, Song, Conversation, Note - // Modules should get the first chance to cancel the event. - - // super.dispatchEvent(event) dispatches event to module scripts. - super.dispatchEvent(event); - - // Dispatch event to stage script. - ScriptEventDispatcher.callEvent(currentStage, event); - - // Dispatch event to character script(s). - if (currentStage != null) currentStage.dispatchToCharacters(event); - - // Dispatch event to song script. - ScriptEventDispatcher.callEvent(currentSong, event); - - // Dispatch event to conversation script. - ScriptEventDispatcher.callEvent(currentConversation, event); - - // TODO: Dispatch event to note scripts - } - - public function startConversation(conversationId:String):Void - { - isInCutscene = true; - - currentConversation = ConversationDataParser.fetchConversation(conversationId); - if (currentConversation == null) return; - - currentConversation.completeCallback = onConversationComplete; - currentConversation.cameras = [camCutscene]; - currentConversation.zIndex = 1000; - add(currentConversation); - refresh(); - - var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false); - ScriptEventDispatcher.callEvent(currentConversation, event); - } - - function onConversationComplete():Void - { - isInCutscene = true; - remove(currentConversation); - currentConversation = null; - - if (startingSong && !isInCountdown) - { - startCountdown(); - } - } - - override function destroy():Void - { - if (currentConversation != null) - { - remove(currentConversation); - currentConversation.kill(); - } - - super.destroy(); - } - - /** - * Updates the position and contents of the score display. - */ - function updateScoreText():Void - { - // TODO: Add functionality for modules to update the score text. - scoreText.text = 'Score:' + songScore; - } - - /** - * Updates the values of the health bar. - */ - function updateHealthBar():Void - { - healthLerp = FlxMath.lerp(healthLerp, health, 0.15); + FlxG.sound.music.pause(); + vocals.pause(); } /** @@ -2498,44 +2515,41 @@ class PlayState extends MusicBeatState FlxG.camera.focusOn(cameraFollowPoint.getPosition()); } + #if debug /** - * Perform necessary cleanup before leaving the PlayState. + * Jumps forward or backward a number of sections in the song. + * Accounts for BPM changes, does not prevent death from skipped notes. + * @param sections The number of sections to jump, negative to go backwards. */ - function performCleanup():Void + function changeSection(sections:Int):Void { - if (currentChart != null) - { - // TODO: Uncache the song. - } + FlxG.sound.music.pause(); - // Remove reference to stage and remove sprites from it to save memory. - if (currentStage != null) - { - remove(currentStage); - currentStage.kill(); - dispatchEvent(new ScriptEvent(ScriptEvent.DESTROY, false)); - currentStage = null; - } + FlxG.sound.music.time += sections * Conductor.measureLengthMs; - GameOverSubState.reset(); + Conductor.update(FlxG.sound.music.time); - // Clear the static reference to this state. - instance = null; - } - - /** - * This function is called whenever Flixel switches switching to a new FlxState. - * @return Whether to actually switch to the new state. - */ - override function switchTo(nextState:FlxState):Bool - { - var result:Bool = super.switchTo(nextState); - - if (result) - { - performCleanup(); - } - - return result; + /** + * + // TODO: Redo this for the new conductor. + var daBPM:Float = Conductor.bpm; + var daPos:Float = 0; + for (i in 0...(Std.int(Conductor.currentStep / 16 + sec))) + { + var section = .getSong()[i]; + if (section == null) continue; + if (section.changeBPM) + { + daBPM = .getSong()[i].bpm; + } + daPos += 4 * (1000 * 60 / daBPM); + } + Conductor.songPosition = FlxG.sound.music.time = daPos; + Conductor.songPosition += Conductor.offset; + + */ + + resyncVocals(); } + #end } diff --git a/source/funkin/play/Strumline.hx b/source/funkin/play/Strumline.hx deleted file mode 100644 index 4bbcc720a..000000000 --- a/source/funkin/play/Strumline.hx +++ /dev/null @@ -1,253 +0,0 @@ -package funkin.play; - -import flixel.FlxSprite; -import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; -import flixel.math.FlxPoint; -import flixel.tweens.FlxEase; -import flixel.tweens.FlxTween; -import funkin.noteStuff.NoteBasic.NoteColor; -import funkin.noteStuff.NoteBasic.NoteDir; -import funkin.noteStuff.NoteBasic.NoteType; -import funkin.ui.PreferencesMenu; -import funkin.util.Constants; - -/** - * A group controlling the individual notes of the strumline for a given player. - * - * FUN FACT: Setting the X and Y of a FlxSpriteGroup will move all the sprites in the group. - */ -class Strumline extends FlxTypedSpriteGroup -{ - /** - * The style of the strumline. - * Options are normal and pixel. - */ - var style:StrumlineStyle; - - /** - * The player this strumline belongs to. - * 0 is Player 1, etc. - */ - var playerId:Int; - - /** - * The number of notes in the strumline. - */ - var size:Int; - - public function new(playerId:Int = 0, style:StrumlineStyle = NORMAL, size:Int = 4) - { - super(0); - this.playerId = playerId; - this.style = style; - this.size = size; - - generateStrumline(); - } - - function generateStrumline():Void - { - for (index in 0...size) - { - createStrumlineArrow(index); - } - } - - function createStrumlineArrow(index:Int):Void - { - var arrow:StrumlineArrow = new StrumlineArrow(index, style); - add(arrow); - } - - /** - * Apply a small animation which moves the arrow down and fades it in. - * Only plays at the start of Free Play songs. - * - * Note that modifying the offset of the whole strumline won't have the - * @param arrow The arrow to animate. - * @param index The index of the arrow in the strumline. - */ - function fadeInArrow(arrow:FlxSprite):Void - { - arrow.y -= 10; - arrow.alpha = 0; - FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * arrow.ID)}); - } - - public function fadeInArrows():Void - { - for (arrow in this.members) - { - fadeInArrow(arrow); - } - } - - function updatePositions() - { - for (arrow in members) - { - arrow.x = Note.swagWidth * arrow.ID; - arrow.x += offset.x; - - arrow.y = 0; - arrow.y += offset.y; - } - } - - /** - * Retrieves the arrow at the given position in the strumline. - * @param index The index to retrieve. - * @return The corresponding FlxSprite. - */ - public inline function getArrow(value:Int):StrumlineArrow - { - // members maintains the order that the arrows were added. - return this.members[value]; - } - - public inline function getArrowByNoteType(value:NoteType):StrumlineArrow - { - return getArrow(value.int); - } - - public inline function getArrowByNoteDir(value:NoteDir):StrumlineArrow - { - return getArrow(value.int); - } - - public inline function getArrowByNoteColor(value:funkin.noteStuff.NoteBasic.NoteColor):StrumlineArrow - { - return getArrow(value.int); - } - - /** - * Get the default Y offset of the strumline. - * @return Int - */ - public static inline function getYPos():Int - { - return PreferencesMenu.getPref('downscroll') ? (FlxG.height - 150) : 50; - } -} - -class StrumlineArrow extends FlxSprite -{ - var style:StrumlineStyle; - - public function new(id:Int, style:StrumlineStyle) - { - super(0, 0); - - this.ID = id; - this.style = style; - - // TODO: Unhardcode this. Maybe use a note style system> - switch (style) - { - case PIXEL: - buildPixelGraphic(); - case NORMAL: - buildNormalGraphic(); - } - - this.updateHitbox(); - scrollFactor.set(0, 0); - animation.play('static'); - } - - public function playAnimation(anim:String, force:Bool = false) - { - animation.play(anim, force); - centerOffsets(); - centerOrigin(); - } - - /** - * Applies the default note style to an arrow. - */ - function buildNormalGraphic():Void - { - this.frames = Paths.getSparrowAtlas('NOTE_assets'); - - this.animation.addByPrefix('green', 'arrowUP'); - this.animation.addByPrefix('blue', 'arrowDOWN'); - this.animation.addByPrefix('purple', 'arrowLEFT'); - this.animation.addByPrefix('red', 'arrowRIGHT'); - - this.setGraphicSize(Std.int(this.width * 0.7)); - this.antialiasing = true; - - this.x += Note.swagWidth * this.ID; - - switch (Math.abs(this.ID)) - { - case 0: - this.animation.addByPrefix('static', 'arrow static instance 1'); - this.animation.addByPrefix('pressed', 'left press', 24, false); - this.animation.addByPrefix('confirm', 'left confirm', 24, false); - case 1: - this.animation.addByPrefix('static', 'arrow static instance 2'); - this.animation.addByPrefix('pressed', 'down press', 24, false); - this.animation.addByPrefix('confirm', 'down confirm', 24, false); - case 2: - this.animation.addByPrefix('static', 'arrow static instance 4'); - this.animation.addByPrefix('pressed', 'up press', 24, false); - this.animation.addByPrefix('confirm', 'up confirm', 24, false); - case 3: - this.animation.addByPrefix('static', 'arrow static instance 3'); - this.animation.addByPrefix('pressed', 'right press', 24, false); - this.animation.addByPrefix('confirm', 'right confirm', 24, false); - } - } - - /** - * Applies the pixel note style to an arrow. - */ - function buildPixelGraphic():Void - { - this.loadGraphic(Paths.image('weeb/pixelUI/arrows-pixels'), true, 17, 17); - - this.animation.add('purplel', [4]); - this.animation.add('blue', [5]); - this.animation.add('green', [6]); - this.animation.add('red', [7]); - - this.setGraphicSize(Std.int(this.width * Constants.PIXEL_ART_SCALE)); - this.updateHitbox(); - - // Forcibly disable anti-aliasing on pixel graphics to stop blur. - this.antialiasing = false; - - this.x += Note.swagWidth * this.ID; - - // TODO: Seems weird that these are hardcoded like this... no XML? - switch (Math.abs(this.ID)) - { - case 0: - this.animation.add('static', [0]); - this.animation.add('pressed', [4, 8], 12, false); - this.animation.add('confirm', [12, 16], 24, false); - case 1: - this.animation.add('static', [1]); - this.animation.add('pressed', [5, 9], 12, false); - this.animation.add('confirm', [13, 17], 24, false); - case 2: - this.animation.add('static', [2]); - this.animation.add('pressed', [6, 10], 12, false); - this.animation.add('confirm', [14, 18], 12, false); - case 3: - this.animation.add('static', [3]); - this.animation.add('pressed', [7, 11], 12, false); - this.animation.add('confirm', [15, 19], 24, false); - } - } -} - -/** - * TODO: Unhardcode this and make it part of the note style system. - */ -enum StrumlineStyle -{ - NORMAL; - PIXEL; -} diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx index bdf7ef591..b27a46a0f 100644 --- a/source/funkin/play/character/BaseCharacter.hx +++ b/source/funkin/play/character/BaseCharacter.hx @@ -2,10 +2,10 @@ package funkin.play.character; import flixel.math.FlxPoint; import funkin.modding.events.ScriptEvent; -import funkin.noteStuff.NoteBasic.NoteDir; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.character.CharacterData.CharacterRenderType; import funkin.play.stage.Bopper; +import funkin.play.notes.NoteDirection; /** * A Character is a stage prop which bops to the music as well as controlled by the strumlines. @@ -488,16 +488,16 @@ class BaseCharacter extends Bopper { super.onNoteHit(event); - if (event.note.mustPress && characterType == BF) + if (event.note.noteData.getMustHitNote() && characterType == BF) { // If the note is from the same strumline, play the sing animation. - this.playSingAnimation(event.note.data.dir, false); + this.playSingAnimation(event.note.noteData.getDirection(), false); holdTimer = 0; } - else if (!event.note.mustPress && characterType == DAD) + else if (!event.note.noteData.getMustHitNote() && characterType == DAD) { // If the note is from the same strumline, play the sing animation. - this.playSingAnimation(event.note.data.dir, false); + this.playSingAnimation(event.note.noteData.getDirection(), false); holdTimer = 0; } } @@ -510,17 +510,17 @@ class BaseCharacter extends Bopper { super.onNoteMiss(event); - if (event.note.mustPress && characterType == BF) + if (event.note.noteData.getMustHitNote() && characterType == BF) { // If the note is from the same strumline, play the sing animation. - this.playSingAnimation(event.note.data.dir, true); + this.playSingAnimation(event.note.noteData.getDirection(), true); } - else if (!event.note.mustPress && characterType == DAD) + else if (!event.note.noteData.getMustHitNote() && characterType == DAD) { // If the note is from the same strumline, play the sing animation. - this.playSingAnimation(event.note.data.dir, true); + this.playSingAnimation(event.note.noteData.getDirection(), true); } - else if (event.note.mustPress && characterType == GF) + else if (event.note.noteData.getMustHitNote() && characterType == GF) { var dropAnim = ''; @@ -575,7 +575,7 @@ class BaseCharacter extends Bopper * @param miss If true, play the miss animation instead of the sing animation. * @param suffix A suffix to append to the animation name, like `alt`. */ - public function playSingAnimation(dir:NoteDir, ?miss:Bool = false, ?suffix:String = ''):Void + public function playSingAnimation(dir:NoteDirection, ?miss:Bool = false, ?suffix:String = ''):Void { var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != '' ? '-${suffix}' : ''}'; diff --git a/source/funkin/play/notes/NoteDirection.hx b/source/funkin/play/notes/NoteDirection.hx new file mode 100644 index 000000000..8a0fb5ecc --- /dev/null +++ b/source/funkin/play/notes/NoteDirection.hx @@ -0,0 +1,82 @@ +package funkin.play.notes; + +import funkin.util.Constants; +import flixel.util.FlxColor; + +/** + * The direction of a note. + * This has implicit casting set up, so you can use this as an integer. + */ +enum abstract NoteDirection(Int) from Int to Int +{ + var LEFT = 0; + var DOWN = 1; + var UP = 2; + var RIGHT = 3; + public var name(get, never):String; + public var nameUpper(get, never):String; + public var color(get, never):FlxColor; + public var colorName(get, never):String; + + @:from + public static function fromInt(value:Int):NoteDirection + { + return switch (value % 4) + { + case 0: LEFT; + case 1: DOWN; + case 2: UP; + case 3: RIGHT; + default: LEFT; + } + } + + function get_name():String + { + return switch (abstract) + { + case LEFT: + 'left'; + case DOWN: + 'down'; + case UP: + 'up'; + case RIGHT: + 'right'; + default: + 'unknown'; + } + } + + function get_nameUpper():String + { + return abstract.name.toUpperCase(); + } + + function get_color():FlxColor + { + return Constants.COLOR_NOTES[this]; + } + + function get_colorName():String + { + return switch (abstract) + { + case LEFT: + 'purple'; + case DOWN: + 'blue'; + case UP: + 'green'; + case RIGHT: + 'red'; + default: + 'unknown'; + } + } + + public function toString():String + { + return abstract.name; + } +} diff --git a/source/funkin/play/notes/NoteSplash.hx b/source/funkin/play/notes/NoteSplash.hx new file mode 100644 index 000000000..90c9825e9 --- /dev/null +++ b/source/funkin/play/notes/NoteSplash.hx @@ -0,0 +1,90 @@ +package funkin.play.notes; + +import funkin.play.notes.NoteDirection; +import flixel.graphics.frames.FlxFramesCollection; +import flixel.FlxG; +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.FlxSprite; + +class NoteSplash extends FlxSprite +{ + static final ALPHA:Float = 0.6; + static final FRAMERATE_DEFAULT:Int = 24; + static final FRAMERATE_VARIANCE:Int = 2; + + static var frameCollection:FlxFramesCollection; + + public static function preloadFrames():Void + { + frameCollection = Paths.getSparrowAtlas('noteSplashes'); + } + + public function new() + { + super(0, 0); + + setup(); + + this.alpha = ALPHA; + this.antialiasing = true; + this.animation.finishCallback = this.onAnimationFinished; + } + + /** + * Add ALL the animations to this sprite. We will recycle and reuse the FlxSprite multiple times. + */ + function setup():Void + { + if (frameCollection == null) preloadFrames(); + + this.frames = frameCollection; + + this.animation.addByPrefix('splash1Left', 'note impact 1 purple0', FRAMERATE_DEFAULT, false, false, false); + this.animation.addByPrefix('splash1Down', 'note impact 1 blue0', FRAMERATE_DEFAULT, false, false, false); + this.animation.addByPrefix('splash1Up', 'note impact 1 green0', FRAMERATE_DEFAULT, false, false, false); + this.animation.addByPrefix('splash1Right', 'note impact 1 red0', FRAMERATE_DEFAULT, false, false, false); + this.animation.addByPrefix('splash2Left', 'note impact 2 purple0', FRAMERATE_DEFAULT, false, false, false); + this.animation.addByPrefix('splash2Down', 'note impact 2 blue0', FRAMERATE_DEFAULT, false, false, false); + this.animation.addByPrefix('splash2Up', 'note impact 2 green0', FRAMERATE_DEFAULT, false, false, false); + this.animation.addByPrefix('splash2Right', 'note impact 2 red0', FRAMERATE_DEFAULT, false, false, false); + + if (this.animation.getAnimationList().length < 8) + { + trace('WARNING: NoteSplash failed to initialize all animations.'); + } + } + + public function playAnimation(name:String, force:Bool = false, reversed:Bool = false, startFrame:Int = 0):Void + { + this.animation.play(name, force, reversed, startFrame); + } + + public function play(direction:NoteDirection, variant:Int = null):Void + { + if (variant == null) variant = FlxG.random.int(1, 2); + + switch (direction) + { + case NoteDirection.LEFT: + this.playAnimation('splash${variant}Left'); + case NoteDirection.DOWN: + this.playAnimation('splash${variant}Down'); + case NoteDirection.UP: + this.playAnimation('splash${variant}Up'); + case NoteDirection.RIGHT: + this.playAnimation('splash${variant}Right'); + } + + // Vary the speed of the animation a bit. + animation.curAnim.frameRate = FRAMERATE_DEFAULT + FlxG.random.int(-FRAMERATE_VARIANCE, FRAMERATE_VARIANCE); + + // Center the animation on the note splash. + offset.set(width * 0.3, height * 0.3); + } + + public function onAnimationFinished(animationName:String):Void + { + // *lightning* *zap* *crackle* + this.kill(); + } +} diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx new file mode 100644 index 000000000..e4b866cc4 --- /dev/null +++ b/source/funkin/play/notes/NoteSprite.hx @@ -0,0 +1,178 @@ +package funkin.play.notes; + +import funkin.play.song.SongData.SongNoteData; +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.FlxSprite; + +class NoteSprite extends FlxSprite +{ + static final DIRECTION_COLORS:Array = ['purple', 'blue', 'green', 'red']; + + public var holdNoteSprite:SustainTrail; + + /** + * The time at which the note should be hit, in milliseconds. + */ + public var strumTime(default, set):Float; + + function set_strumTime(value:Float):Float + { + this.strumTime = value; + return this.strumTime; + } + + /** + * The length of the note's sustain, in milliseconds. + * If 0, the note is a tap note. + */ + public var length(default, set):Float; + + function set_length(value:Float):Float + { + this.length = value; + this.isSustainNote = (this.length > 0); + return this.length; + } + + /** + * The time at which the note should be hit, in steps. + */ + public var stepTime(get, never):Float; + + function get_stepTime():Float + { + // TODO: Account for changes in BPM. + return this.strumTime / Conductor.stepLengthMs; + } + + /** + * An extra attribute for the note. + * For example, whether the note is an "alt" note, or whether it has custom behavior on hit. + */ + public var kind(default, set):String; + + function set_kind(value:String):String + { + this.kind = value; + return this.kind; + } + + /** + * The data of the note (i.e. the direction.) + */ + public var direction(default, set):NoteDirection; + + function set_direction(value:Int):Int + { + if (frames == null) return value; + + animation.play(DIRECTION_COLORS[value] + 'Scroll'); + + this.direction = value; + return this.direction; + } + + public var noteData:SongNoteData; + + public var isSustainNote:Bool = false; + + /** + * Set this flag to true when hitting the note to avoid scoring it multiple times. + */ + public var hasBeenHit:Bool = false; + + /** + * Register this note as hit only after any other notes + */ + public var lowPriority:Bool = false; + + /** + * This is true if the note has been fully missed by the player. + * It will be destroyed immediately. + */ + public var hasMissed:Bool; + + /** + * This is true if the note is earlier than 10 frames within the strumline. + * and thus can't be hit by the player. + * Managed by PlayState. + */ + public var tooEarly:Bool; + + /** + * This is true if the note is within 10 frames of the strumline, + * and thus may be hit by the player. + * Managed by PlayState. + */ + public var mayHit:Bool; + + /** + * This is true if the note is earlier than 10 frames after the strumline, + * and thus can't be hit by the player. + * Managed by PlayState. + */ + public var tooLate:Bool; + + public function new(strumTime:Float = 0, direction:Int = 0) + { + super(0, -9999); + this.strumTime = strumTime; + this.direction = direction; + + if (this.strumTime < 0) this.strumTime = 0; + + setupNoteGraphic(); + + // Disables the update() function for performance. + this.active = false; + } + + public static function buildNoteFrames(force:Bool = false):FlxAtlasFrames + { + // static variables inside functions are a cool of Haxe 4.3.0. + static var noteFrames:FlxAtlasFrames = null; + + if (noteFrames != null && !force) return noteFrames; + + noteFrames = Paths.getSparrowAtlas('NOTE_assets'); + + noteFrames.parent.persist = true; + + return noteFrames; + } + + function setupNoteGraphic():Void + { + this.frames = buildNoteFrames(); + + animation.addByPrefix('greenScroll', 'green instance'); + animation.addByPrefix('redScroll', 'red instance'); + animation.addByPrefix('blueScroll', 'blue instance'); + animation.addByPrefix('purpleScroll', 'purple instance'); + + animation.addByPrefix('purpleholdend', 'pruple end hold'); + animation.addByPrefix('greenholdend', 'green hold end'); + animation.addByPrefix('redholdend', 'red hold end'); + animation.addByPrefix('blueholdend', 'blue hold end'); + + animation.addByPrefix('purplehold', 'purple hold piece'); + animation.addByPrefix('greenhold', 'green hold piece'); + animation.addByPrefix('redhold', 'red hold piece'); + animation.addByPrefix('bluehold', 'blue hold piece'); + + setGraphicSize(Strumline.STRUMLINE_SIZE); + updateHitbox(); + antialiasing = true; + } + + public override function revive():Void + { + super.revive(); + this.active = false; + this.tooEarly = false; + this.hasBeenHit = false; + this.mayHit = false; + this.tooLate = false; + this.hasMissed = false; + } +} diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx new file mode 100644 index 000000000..7be17a4cb --- /dev/null +++ b/source/funkin/play/notes/Strumline.hx @@ -0,0 +1,565 @@ +package funkin.play.notes; + +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import funkin.ui.PreferencesMenu; +import funkin.play.notes.NoteSprite; +import flixel.util.FlxSort; +import funkin.play.notes.SustainTrail; +import funkin.util.SortUtil; +import funkin.play.song.SongData.SongNoteData; +import flixel.FlxG; +import flixel.group.FlxSpriteGroup; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; + +/** + * A group of sprites which handles the receptor, the note splashes, and the notes (with sustains) for a given player. + */ +class Strumline extends FlxSpriteGroup +{ + public static final DIRECTIONS:Array = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT]; + public static final STRUMLINE_SIZE:Int = 112; + public static final NOTE_SPACING:Int = STRUMLINE_SIZE + 8; + + // Positional fixes for new strumline graphics. + static final INITIAL_OFFSET = -0.275 * STRUMLINE_SIZE; + static final NUDGE:Float = 2.0; + + static final KEY_COUNT:Int = 4; + static final NOTE_SPLASH_CAP:Int = 6; + + static var RENDER_DISTANCE_MS(get, null):Float; + + static function get_RENDER_DISTANCE_MS():Float + { + return FlxG.height / 0.45; + } + + public var isPlayer:Bool; + + /** + * The notes currently being rendered on the strumline. + * This group iterates over this every frame to update note positions. + * The PlayState also iterates over this to calculate user inputs. + */ + public var notes:FlxTypedSpriteGroup; + + public var holdNotes:FlxTypedSpriteGroup; + + var strumlineNotes:FlxTypedSpriteGroup; + var noteSplashes:FlxTypedSpriteGroup; + var sustainSplashes:FlxTypedSpriteGroup; + + var noteData:Array = []; + var nextNoteIndex:Int = -1; + + public function new(isPlayer:Bool) + { + super(); + + this.isPlayer = isPlayer; + + this.strumlineNotes = new FlxTypedSpriteGroup(); + this.add(this.strumlineNotes); + + // Hold notes are added first so they render behind regular notes. + this.holdNotes = new FlxTypedSpriteGroup(); + this.add(this.holdNotes); + + this.notes = new FlxTypedSpriteGroup(); + this.add(this.notes); + + this.noteSplashes = new FlxTypedSpriteGroup(0, 0, NOTE_SPLASH_CAP); + this.add(this.noteSplashes); + + for (i in 0...DIRECTIONS.length) + { + var child:StrumlineNote = new StrumlineNote(isPlayer, DIRECTIONS[i]); + child.x = getXPos(DIRECTIONS[i]); + child.x += INITIAL_OFFSET; + child.y = 0; + this.strumlineNotes.add(child); + } + + // This MUST be true for children to update! + this.active = true; + } + + override function get_width():Float + { + return 4 * Strumline.NOTE_SPACING; + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + updateNotes(); + } + + /** + * Get a list of notes within + or - the given strumtime. + * @param strumTime The current time. + * @param hitWindow The hit window to check. + */ + public function getNotesInRange(strumTime:Float, hitWindow:Float):Array + { + var hitWindowStart:Float = strumTime - hitWindow; + var hitWindowEnd:Float = strumTime + hitWindow; + + return notes.members.filter(function(note:NoteSprite) { + return note != null && note.alive && !note.hasBeenHit && note.strumTime >= hitWindowStart && note.strumTime <= hitWindowEnd; + }); + } + + public function getHoldNotesInRange(strumTime:Float, hitWindow:Float):Array + { + var hitWindowStart:Float = strumTime - hitWindow; + var hitWindowEnd:Float = strumTime + hitWindow; + + return holdNotes.members.filter(function(note:SustainTrail) { + return note != null + && note.alive + && note.strumTime >= hitWindowStart + && (note.strumTime + note.fullSustainLength) <= hitWindowEnd; + }); + } + + public function getNoteSprite(noteData:SongNoteData):NoteSprite + { + if (noteData == null) return null; + + for (note in notes.members) + { + if (note == null) continue; + if (note.alive) continue; + + if (note.noteData == noteData) return note; + } + + return null; + } + + public function getHoldNoteSprite(noteData:SongNoteData):SustainTrail + { + if (noteData == null || ((noteData.length ?? 0.0) <= 0.0)) return null; + + for (holdNote in holdNotes.members) + { + if (holdNote == null) continue; + if (holdNote.alive) continue; + + if (holdNote.noteData == noteData) return holdNote; + } + + return null; + } + + /** + * For a note's strumTime, calculate its Y position relative to the strumline. + * NOTE: Assumes Conductor and PlayState are both initialized. + * @param strumTime + * @return Float + */ + static function calculateNoteYPos(strumTime:Float):Float + { + // Make the note move faster visually as it moves offscreen. + var vwoosh:Float = (strumTime < Conductor.songPosition) ? 2.0 : 1.0; + var scrollSpeed:Float = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0; + + return Conductor.PIXELS_PER_MS * (Conductor.songPosition - strumTime) * scrollSpeed * vwoosh * (PreferencesMenu.getPref('downscroll') ? 1 : -1); + } + + function updateNotes():Void + { + if (noteData.length == 0) return; + + var renderWindowStart:Float = Conductor.songPosition + RENDER_DISTANCE_MS; + + for (noteIndex in nextNoteIndex...noteData.length) + { + var note:Null = noteData[noteIndex]; + + if (note == null) continue; + if (note.time > renderWindowStart) break; + + buildNoteSprite(note); + + if (note.length > 0) + { + buildHoldNoteSprite(note); + } + + nextNoteIndex++; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow. + } + + // Update rendering of notes. + for (note in notes.members) + { + if (note == null || note.hasBeenHit) continue; + + note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime); + + // Check if the note is outside the hit window, and if so, mark it as missed. + // TODO: Check to make sure this doesn't happen when the note is on screen because it'll probably get deleted. + if (Conductor.songPosition > (note.noteData.time + Conductor.HIT_WINDOW_MS)) + { + note.visible = false; + note.hasMissed = true; + if (note.holdNoteSprite != null) note.holdNoteSprite.missed = true; + } + else + { + note.visible = true; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missed = false; + } + } + + // Update rendering of hold notes. + for (holdNote in holdNotes.members) + { + if (holdNote == null || !holdNote.alive) continue; + + var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Conductor.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8; + + if (Conductor.songPosition >= renderWindowEnd || holdNote.sustainLength <= 0) + { + // Hold note is offscreen, kill it. + holdNote.visible = false; + holdNote.kill(); // Do not destroy! Recycling is faster. + } + else if (holdNote.sustainLength <= 0) + { + // Hold note is completed, kill it. + playStatic(holdNote.noteDirection); + holdNote.visible = false; + holdNote.kill(); + } + else if (holdNote.sustainLength <= 10) + { + // TODO: Better handle the weird edge case where the hold note is almost completed. + holdNote.visible = false; + } + else if (Conductor.songPosition > holdNote.strumTime && !holdNote.missed) + { + // Hold note is currently being hit, clip it off. + holdConfirm(holdNote.noteDirection); + holdNote.visible = true; + + holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - Conductor.songPosition; + + if (PreferencesMenu.getPref('downscroll')) + { + holdNote.y = this.y - holdNote.height + STRUMLINE_SIZE / 2; + } + else + { + holdNote.y = this.y - INITIAL_OFFSET + STRUMLINE_SIZE / 2; + } + } + else if (holdNote.missed && (holdNote.fullSustainLength > holdNote.sustainLength)) + { + // Hold note was dropped before completing, keep it in its clipped state. + holdNote.visible = true; + + var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Conductor.PIXELS_PER_MS; + + trace('yOffset: ' + yOffset); + trace('holdNote.fullSustainLength: ' + holdNote.fullSustainLength); + trace('holdNote.sustainLength: ' + holdNote.sustainLength); + + if (PreferencesMenu.getPref('downscroll')) + { + holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime) - holdNote.height + STRUMLINE_SIZE / 2; + } + else + { + holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + yOffset + STRUMLINE_SIZE / 2; + } + } + else + { + // Hold note is new, render it normally. + holdNote.visible = true; + + if (PreferencesMenu.getPref('downscroll')) + { + holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime) - holdNote.height + STRUMLINE_SIZE / 2; + } + else + { + holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + STRUMLINE_SIZE / 2; + } + } + } + } + + public function onBeatHit():Void + { + if (notes.members.length > 1) notes.members.insertionSort(compareNoteSprites.bind(FlxSort.ASCENDING)); + + if (holdNotes.members.length > 1) holdNotes.members.insertionSort(compareHoldNoteSprites.bind(FlxSort.ASCENDING)); + } + + public function applyNoteData(data:Array):Void + { + this.notes.clear(); + + this.noteData = data.copy(); + this.nextNoteIndex = 0; + + // Sort the notes by strumtime. + this.noteData.insertionSort(compareNoteData.bind(FlxSort.ASCENDING)); + } + + public function hitNote(note:NoteSprite):Void + { + playConfirm(note.direction); + killNote(note); + } + + public function killNote(note:NoteSprite):Void + { + note.visible = false; + notes.remove(note, false); + note.kill(); + + if (note.holdNoteSprite != null) + { + holdNoteSprite.missed = true; + holdNoteSprite.alpha = 0.6; + } + } + + public function getByIndex(index:Int):StrumlineNote + { + return this.strumlineNotes.members[index]; + } + + public function getByDirection(direction:NoteDirection):StrumlineNote + { + return getByIndex(DIRECTIONS.indexOf(direction)); + } + + public function playStatic(direction:NoteDirection):Void + { + getByDirection(direction).playStatic(); + } + + public function playPress(direction:NoteDirection):Void + { + getByDirection(direction).playPress(); + } + + public function playConfirm(direction:NoteDirection):Void + { + getByDirection(direction).playConfirm(); + } + + public function holdConfirm(direction:NoteDirection):Void + { + getByDirection(direction).holdConfirm(); + } + + public function isConfirm(direction:NoteDirection):Bool + { + return getByDirection(direction).isConfirm(); + } + + public function playNoteSplash(direction:NoteDirection):Void + { + // TODO: Add a setting to disable note splashes. + // if (Settings.noSplash) return; + + var splash:NoteSplash = this.constructNoteSplash(); + + if (splash != null) + { + splash.play(direction); + + splash.x = this.x; + splash.x += getXPos(direction); + splash.x += INITIAL_OFFSET; + splash.y = this.y; + splash.y -= INITIAL_OFFSET; + splash.y += 0; + } + } + + public function buildNoteSprite(note:SongNoteData):Void + { + var noteSprite:NoteSprite = constructNoteSprite(); + + if (noteSprite != null) + { + noteSprite.strumTime = note.time; + noteSprite.direction = note.getDirection(); + noteSprite.noteData = note; + + noteSprite.x = this.x; + noteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); + noteSprite.x -= NUDGE; + // noteSprite.x += INITIAL_OFFSET; + noteSprite.y = -9999; + } + } + + public function buildHoldNoteSprite(note:SongNoteData):Void + { + var holdNoteSprite:SustainTrail = constructHoldNoteSprite(); + + if (holdNoteSprite != null) + { + holdNoteSprite.noteData = note; + holdNoteSprite.strumTime = note.time; + holdNoteSprite.noteDirection = note.getDirection(); + holdNoteSprite.fullSustainLength = note.length; + holdNoteSprite.sustainLength = note.length; + holdNoteSprite.missed = false; + + holdNoteSprite.x = this.x; + holdNoteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); + // holdNoteSprite.x += INITIAL_OFFSET; + holdNoteSprite.x += STRUMLINE_SIZE / 2; + holdNoteSprite.x -= holdNoteSprite.width / 2; + holdNoteSprite.y = -9999; + } + } + + /** + * Custom recycling behavior. + */ + function constructNoteSplash():NoteSplash + { + var result:NoteSplash = null; + + // If we haven't filled the pool yet... + if (noteSplashes.length < noteSplashes.maxSize) + { + // Create a new note splash. + result = new NoteSplash(); + this.noteSplashes.add(result); + } + else + { + // Else, find a note splash which is inactive so we can revive it. + result = this.noteSplashes.getFirstAvailable(); + + if (result != null) + { + result.revive(); + } + else + { + // The note splash pool is full and all note splashes are active, + // so we just pick one at random to destroy and restart. + result = FlxG.random.getObject(this.noteSplashes.members); + } + } + + return result; + } + + /** + * Custom recycling behavior. + */ + function constructNoteSprite():NoteSprite + { + var result:NoteSprite = null; + + // Else, find a note which is inactive so we can revive it. + result = this.notes.getFirstAvailable(); + + if (result != null) + { + // Revive and reuse the note. + result.revive(); + } + else + { + // The note sprite pool is full and all note splashes are active. + // We have to create a new note. + result = new NoteSprite(); + this.notes.add(result); + } + + return result; + } + + /** + * Custom recycling behavior. + */ + function constructHoldNoteSprite():SustainTrail + { + var result:SustainTrail = null; + + // Else, find a note which is inactive so we can revive it. + result = this.holdNotes.getFirstAvailable(); + + if (result != null) + { + // Revive and reuse the note. + result.revive(); + } + else + { + // The note sprite pool is full and all note splashes are active. + // We have to create a new note. + result = new SustainTrail(0, 100, Paths.image("NOTE_hold_assets")); + this.holdNotes.add(result); + } + + return result; + } + + function getXPos(direction:NoteDirection):Float + { + return switch (direction) + { + case NoteDirection.LEFT: 0; + case NoteDirection.DOWN: 0 + (1 * Strumline.NOTE_SPACING); + case NoteDirection.UP: 0 + (2 * Strumline.NOTE_SPACING); + case NoteDirection.RIGHT: 0 + (3 * Strumline.NOTE_SPACING); + default: 0; + } + } + + /** + * Apply a small animation which moves the arrow down and fades it in. + * Only plays at the start of Free Play songs. + * + * Note that modifying the offset of the whole strumline won't have the + * @param arrow The arrow to animate. + * @param index The index of the arrow in the strumline. + */ + function fadeInArrow(arrow:StrumlineNote):Void + { + arrow.y -= 10; + arrow.alpha = 0; + FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * arrow.ID)}); + } + + public function fadeInArrows():Void + { + for (arrow in this.strumlineNotes) + { + fadeInArrow(arrow); + } + } + + function compareNoteData(order:Int, a:SongNoteData, b:SongNoteData):Int + { + return FlxSort.byValues(order, a.time, b.time); + } + + function compareNoteSprites(order:Int, a:NoteSprite, b:NoteSprite):Int + { + return FlxSort.byValues(order, a?.strumTime, b?.strumTime); + } + + function compareHoldNoteSprites(order:Int, a:SustainTrail, b:SustainTrail):Int + { + return FlxSort.byValues(order, a?.strumTime, b?.strumTime); + } +} diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx new file mode 100644 index 000000000..7fbb3a0f9 --- /dev/null +++ b/source/funkin/play/notes/StrumlineNote.hx @@ -0,0 +1,187 @@ +package funkin.play.notes; + +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.FlxSprite; +import funkin.play.notes.NoteSprite; + +/** + * The actual receptor that you see on screen. + */ +class StrumlineNote extends FlxSprite +{ + public var isPlayer(default, null):Bool; + + public var direction(default, set):NoteDirection; + + public function updatePosition(parentNote:NoteSprite) + { + this.x = parentNote.x; + this.x += parentNote.width / 2; + this.x -= this.width / 2; + + this.y = parentNote.y; + this.y += parentNote.height / 2; + } + + function set_direction(value:NoteDirection):NoteDirection + { + this.direction = value; + setup(); + return this.direction; + } + + public function new(isPlayer:Bool, direction:NoteDirection) + { + super(0, 0); + + this.isPlayer = isPlayer; + + this.direction = direction; + + this.animation.callback = onAnimationFrame; + this.animation.finishCallback = onAnimationFinished; + + this.active = true; + } + + function onAnimationFrame(name:String, frameNumber:Int, frameIndex:Int):Void {} + + function onAnimationFinished(name:String):Void + { + if (!isPlayer && name.startsWith('confirm')) + { + playStatic(); + } + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + centerOrigin(); + } + + function setup():Void + { + this.frames = Paths.getSparrowAtlas('StrumlineNotes'); + + switch (this.direction) + { + case NoteDirection.LEFT: + this.animation.addByIndices('static', 'left confirm', [6, 7], '', 24, false, false, false); + this.animation.addByPrefix('press', 'left press', 24, false, false, false); + this.animation.addByIndices('confirm', 'left confirm', [0, 1, 2, 3], '', 24, false, false, false); + this.animation.addByIndices('confirm-hold', 'left confirm', [2, 3, 4, 5], '', 24, true, false, false); + + case NoteDirection.DOWN: + this.animation.addByIndices('static', 'down confirm', [6, 7], '', 24, false, false, false); + this.animation.addByPrefix('press', 'down press', 24, false, false, false); + this.animation.addByIndices('confirm', 'down confirm', [0, 1, 2, 3], '', 24, false, false, false); + this.animation.addByIndices('confirm-hold', 'down confirm', [2, 3, 4, 5], '', 24, true, false, false); + + case NoteDirection.UP: + this.animation.addByIndices('static', 'up confirm', [6, 7], '', 24, false, false, false); + this.animation.addByPrefix('press', 'up press', 24, false, false, false); + this.animation.addByIndices('confirm', 'up confirm', [0, 1, 2, 3], '', 24, false, false, false); + this.animation.addByIndices('confirm-hold', 'up confirm', [2, 3, 4, 5], '', 24, true, false, false); + + case NoteDirection.RIGHT: + this.animation.addByIndices('static', 'right confirm', [6, 7], '', 24, false, false, false); + this.animation.addByPrefix('press', 'right press', 24, false, false, false); + this.animation.addByIndices('confirm', 'right confirm', [0, 1, 2, 3], '', 24, false, false, false); + this.animation.addByIndices('confirm-hold', 'right confirm', [2, 3, 4, 5], '', 24, true, false, false); + } + + this.antialiasing = true; + + this.setGraphicSize(Std.int(Strumline.STRUMLINE_SIZE * 1.55)); + this.updateHitbox(); + this.playStatic(); + } + + public function playAnimation(name:String = 'static', force:Bool = false, reversed:Bool = false, startFrame:Int = 0):Void + { + this.animation.play(name, force, reversed, startFrame); + + centerOffsets(); + centerOrigin(); + } + + public function playStatic():Void + { + this.active = false; + this.playAnimation('static', true); + } + + public function playPress():Void + { + this.active = true; + this.playAnimation('press', true); + } + + public function playConfirm():Void + { + this.active = true; + this.playAnimation('confirm', true); + } + + public function isConfirm():Bool + { + return getCurrentAnimation().startsWith('confirm'); + } + + public function holdConfirm():Void + { + this.active = true; + + if (getCurrentAnimation() == "confirm-hold") return; + if (getCurrentAnimation() == "confirm") + { + if (isAnimationFinished()) + { + this.playAnimation('confirm-hold', true, false); + } + return; + } + this.playAnimation('confirm', false, false); + } + + /** + * Returns the name of the animation that is currently playing. + * If no animation is playing (usually this means the sprite is BROKEN!), + * returns an empty string to prevent NPEs. + */ + public function getCurrentAnimation():String + { + if (this.animation == null || this.animation.curAnim == null) return ""; + return this.animation.curAnim.name; + } + + public function isAnimationFinished():Bool + { + return this.animation.finished; + } + + static final DEFAULT_OFFSET:Int = 13; + + /** + * Adjusts the position of the sprite's graphic relative to the hitbox. + */ + function fixOffsets():Void + { + // Automatically center the bounding box within the graphic. + this.centerOffsets(); + + if (getCurrentAnimation() == "confirm") + { + // Move the graphic down and to the right to compensate for + // the "glow" effect on the strumline note. + this.offset.x -= DEFAULT_OFFSET; + this.offset.y -= DEFAULT_OFFSET; + } + else + { + this.centerOrigin(); + } + } +} diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx new file mode 100644 index 000000000..0b84f2d64 --- /dev/null +++ b/source/funkin/play/notes/SustainTrail.hx @@ -0,0 +1,272 @@ +package funkin.play.notes; + +import funkin.play.notes.NoteDirection; +import funkin.play.song.SongData.SongNoteData; +import flixel.util.FlxDirectionFlags; +import flixel.FlxSprite; +import flixel.graphics.FlxGraphic; +import flixel.graphics.tile.FlxDrawTrianglesItem; +import flixel.math.FlxMath; +import funkin.ui.PreferencesMenu; + +/** + * This is based heavily on the `FlxStrip` class. It uses `drawTriangles()` to clip a sustain note + * trail at a certain time. + * The whole `FlxGraphic` is used as a texture map. See the `NOTE_hold_assets.fla` file for specifics + * on how it should be constructed. + * + * @author MtH + */ +class SustainTrail extends FlxSprite +{ + /** + * The triangles corresponding to the hold, followed by the endcap. + * `top left, top right, bottom left` + * `top left, bottom left, bottom right` + */ + static final TRIANGLE_VERTEX_INDICES:Array = [0, 1, 2, 1, 2, 3, 4, 5, 6, 5, 6, 7]; + + public var strumTime:Float = 0; // millis + public var noteDirection:NoteDirection = 0; + public var sustainLength(default, set):Float = 0; // millis + public var fullSustainLength:Float = 0; + public var noteData:SongNoteData; + + /** + * Set to `true` if the user missed the note. + * The trail should be made transparent, with clipping and effects disabled + */ + public var missed:Bool = false; // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support! + + /** + * A `Vector` of floats where each pair of numbers is treated as a coordinate location (an x, y pair). + */ + public var vertices:DrawData = new DrawData(); + + /** + * A `Vector` of integers or indexes, where every three indexes define a triangle. + */ + public var indices:DrawData = new DrawData(); + + /** + * A `Vector` of normalized coordinates used to apply texture mapping. + */ + public var uvtData:DrawData = new DrawData(); + + private var processedGraphic:FlxGraphic; + + private var zoom:Float = 1; + + /** + * What part of the trail's end actually represents the end of the note. + * This can be used to have a little bit sticking out. + */ + public var endOffset:Float = 0.5; // 0.73 is roughly the bottom of the sprite in the normal graphic! + + /** + * At what point the bottom for the trail's end should be clipped off. + * Used in cases where there's an extra bit of the graphic on the bottom to avoid antialiasing issues with overflow. + */ + public var bottomClip:Float = 0.9; + + /** + * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?) + * @param NoteData + * @param SustainLength Length in milliseconds. + * @param fileName + */ + public function new(noteDirection:NoteDirection, sustainLength:Float, fileName:String) + { + super(0, 0, fileName); + + antialiasing = true; + if (fileName == "arrowEnds") + { + endOffset = bottomClip = 1; + antialiasing = false; + zoom = 6; + } + // BASIC SETUP + this.sustainLength = sustainLength; + this.fullSustainLength = sustainLength; + this.noteDirection = noteDirection; + + zoom *= 0.7; + + // CALCULATE SIZE + width = graphic.width / 8 * zoom; // amount of notes * 2 + height = sustainHeight(sustainLength, PlayState.instance.currentChart.scrollSpeed); + // instead of scrollSpeed, PlayState.SONG.speed + + flipY = PreferencesMenu.getPref('downscroll'); + + // alpha = 0.6; + alpha = 1.0; + // calls updateColorTransform(), which initializes processedGraphic! + updateColorTransform(); + + updateClipping(); + indices = new DrawData(12, true, TRIANGLE_VERTEX_INDICES); + } + + /** + * Calculates height of a sustain note for a given length (milliseconds) and scroll speed. + * @param susLength The length of the sustain note in milliseconds. + * @param scroll The current scroll speed. + */ + public static inline function sustainHeight(susLength:Float, scroll:Float) + { + return (susLength * 0.45 * scroll); + } + + function set_sustainLength(s:Float) + { + if (s < 0) s = 0; + + height = sustainHeight(s, PlayState.instance.currentChart.scrollSpeed); + updateColorTransform(); + updateClipping(); + return sustainLength = s; + } + + /** + * Sets up new vertex and UV data to clip the trail. + * If flipY is true, top and bottom bounds swap places. + * @param songTime The time to clip the note at, in milliseconds. + */ + public function updateClipping(songTime:Float = 0):Void + { + var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), PlayState.instance.currentChart.scrollSpeed), 0, height); + if (clipHeight == 0) + { + visible = false; + return; + } + else + visible = true; + + var bottomHeight:Float = graphic.height * zoom * endOffset; + var partHeight:Float = clipHeight - bottomHeight; + + // ===HOLD VERTICES== + // Top left + vertices[0 * 2] = 0.0; // Inline with left side + vertices[0 * 2 + 1] = flipY ? clipHeight : height - clipHeight; + + // Top right + vertices[1 * 2] = width; + vertices[1 * 2 + 1] = vertices[0 * 2 + 1]; // Inline with top left vertex + + // Bottom left + vertices[2 * 2] = 0.0; // Inline with left side + vertices[2 * 2 + 1] = if (partHeight > 0) + { + // flipY makes the sustain render upside down. + flipY ? 0.0 + bottomHeight : vertices[1] + partHeight; + } + else + { + vertices[0 * 2 + 1]; // Inline with top left vertex (no partHeight available) + } + + // Bottom right + vertices[3 * 2] = width; + vertices[3 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex + + // ===HOLD UVs=== + + // The UVs are a bit more complicated. + // UV coordinates are normalized, so they range from 0 to 1. + // We are expecting an image containing 8 horizontal segments, each representing a different colored hold note followed by its end cap. + + uvtData[0 * 2] = 1 / 4 * (noteDirection % 4); // 0%/25%/50%/75% of the way through the image + uvtData[0 * 2 + 1] = (-partHeight) / graphic.height / zoom; // top bound + // Top left + + // Top right + uvtData[1 * 2] = uvtData[0 * 2] + 1 / 8; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left) + uvtData[1 * 2 + 1] = uvtData[0 * 2 + 1]; // top bound + + // Bottom left + uvtData[2 * 2] = uvtData[0 * 2]; // 0%/25%/50%/75% of the way through the image + uvtData[2 * 2 + 1] = 0.0; // bottom bound + + // Bottom right + uvtData[3 * 2] = uvtData[1 * 2]; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left) + uvtData[3 * 2 + 1] = uvtData[2 * 2 + 1]; // bottom bound + + // === END CAP VERTICES === + // Top left + vertices[4 * 2] = vertices[2 * 2]; // Inline with bottom left vertex of hold + vertices[4 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex of hold + + // Top right + vertices[5 * 2] = vertices[3 * 2]; // Inline with bottom right vertex of hold + vertices[5 * 2 + 1] = vertices[3 * 2 + 1]; // Inline with bottom right vertex of hold + + // Bottom left + vertices[6 * 2] = vertices[2 * 2]; // Inline with left side + vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (height + graphic.height * (bottomClip - endOffset) * zoom); + + // Bottom right + vertices[7 * 2] = vertices[3 * 2]; // Inline with right side + vertices[7 * 2 + 1] = vertices[6 * 2 + 1]; // Inline with bottom of end cap + + // === END CAP UVs === + // Top left + uvtData[4 * 2] = uvtData[2 * 2] + 1 / 8; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left of hold) + uvtData[4 * 2 + 1] = if (partHeight > 0) + { + 0; + } + else + { + (bottomHeight - clipHeight) / zoom / graphic.height; + }; + + // Top right + uvtData[5 * 2] = uvtData[4 * 2] + 1 / 8; // 25%/50%/75%/100% of the way through the image (1/8th past the top left of cap) + uvtData[5 * 2 + 1] = uvtData[4 * 2 + 1]; // top bound + + // Bottom left + uvtData[6 * 2] = uvtData[4 * 2]; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left of hold) + uvtData[6 * 2 + 1] = bottomClip; // bottom bound + + // Bottom right + uvtData[7 * 2] = uvtData[5 * 2]; // 25%/50%/75%/100% of the way through the image (1/8th past the top left of cap) + uvtData[7 * 2 + 1] = uvtData[6 * 2 + 1]; // bottom bound + } + + @:access(flixel.FlxCamera) + override public function draw():Void + { + if (alpha == 0 || graphic == null || vertices == null) return; + + for (camera in cameras) + { + if (!camera.visible || !camera.exists) continue; + // if (!isOnScreen(camera)) continue; // TODO: Update this code to make it work properly. + + getScreenPosition(_point, camera).subtractPoint(offset); + camera.drawTriangles(processedGraphic, vertices, indices, uvtData, null, _point, blend, true, antialiasing); + } + } + + override public function destroy():Void + { + vertices = null; + indices = null; + uvtData = null; + processedGraphic.destroy(); + + super.destroy(); + } + + override function updateColorTransform():Void + { + super.updateColorTransform(); + if (processedGraphic != null) processedGraphic.destroy(); + processedGraphic = FlxGraphic.fromGraphic(graphic, true); + processedGraphic.bitmap.colorTransform(processedGraphic.bitmap.rect, colorTransform); + } +} diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 7de005cb0..b42c8e7c4 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -298,9 +298,16 @@ class SongDifficulty return cast events; } - public inline function cacheInst():Void + public inline function cacheInst(?currentPlayerId:String = null):Void { - FlxG.sound.cache(Paths.inst(this.song.songId)); + if (currentPlayerId != null) + { + FlxG.sound.cache(Paths.inst(this.song.songId, getPlayableChar(currentPlayerId).inst)); + } + else + { + FlxG.sound.cache(Paths.inst(this.song.songId)); + } } public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx index a744c9a65..dc46ae365 100644 --- a/source/funkin/play/song/SongData.hx +++ b/source/funkin/play/song/SongData.hx @@ -427,6 +427,12 @@ abstract SongNoteData(RawSongNoteData) return Math.floor(this.d / strumlineSize); } + /** + * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side). + * TODO: The name of this function is a little misleading; what about mines? + * @param strumlineSize Defaults to 4. + * @return True if it's Boyfriend's note. + */ public inline function getMustHitNote(strumlineSize:Int = 4):Bool { return getStrumlineIndex(strumlineSize) == 0; diff --git a/source/funkin/ui/ColorsMenu.hx b/source/funkin/ui/ColorsMenu.hx index 9ebccf1c9..68fc7e7e0 100644 --- a/source/funkin/ui/ColorsMenu.hx +++ b/source/funkin/ui/ColorsMenu.hx @@ -5,23 +5,24 @@ import flixel.addons.effects.chainable.FlxOutlineEffect; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.util.FlxColor; import funkin.ui.OptionsState.Page; +import funkin.play.notes.NoteSprite; class ColorsMenu extends Page { var curSelected:Int = 0; - var grpNotes:FlxTypedGroup; + var grpNotes:FlxTypedGroup; public function new() { super(); - grpNotes = new FlxTypedGroup(); + grpNotes = new FlxTypedGroup(); add(grpNotes); for (i in 0...4) { - var note:Note = new Note(0, i); + var note:NoteSprite = new NoteSprite(0, i); note.x = (100 * i) + i; note.screenCenter(Y); @@ -52,14 +53,14 @@ class ColorsMenu extends Page if (controls.UI_UP) { - grpNotes.members[curSelected].colorSwap.update(elapsed * 0.3); - Note.arrowColors[curSelected] += elapsed * 0.3; + // grpNotes.members[curSelected].colorSwap.update(elapsed * 0.3); + // Note.arrowColors[curSelected] += elapsed * 0.3; } if (controls.UI_DOWN) { - grpNotes.members[curSelected].colorSwap.update(-elapsed * 0.3); - Note.arrowColors[curSelected] += -elapsed * 0.3; + // grpNotes.members[curSelected].colorSwap.update(-elapsed * 0.3); + // Note.arrowColors[curSelected] += -elapsed * 0.3; } super.update(elapsed); diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index a23a04231..566e75706 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -22,6 +22,7 @@ import funkin.audio.VoicesGroup; import funkin.input.Cursor; import funkin.modding.events.ScriptEvent; import funkin.play.HealthIcon; +import funkin.play.notes.NoteSprite; import funkin.play.song.Song; import funkin.play.song.SongData.SongChartData; import funkin.play.song.SongData.SongDataParser; @@ -2803,11 +2804,9 @@ class ChartEditorState extends HaxeUIState // Character preview. - // Why does NOTESCRIPTEVENT TAKE A SPRITE AAAAA - var tempNote:Note = new Note(noteData.time, noteData.data, null, false, NORMAL); - tempNote.mustPress = noteData.getMustHitNote(); - tempNote.data.sustainLength = noteData.length; - tempNote.data.noteKind = noteData.kind; + // NoteScriptEvent takes a sprite, ehe. Need to rework that. + var tempNote:NoteSprite = new NoteSprite(); + tempNote.noteData = noteData; tempNote.scrollFactor.set(0, 0); var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true); dispatchEvent(event); diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx index c1bac76c4..bcf0f7359 100644 --- a/source/funkin/util/Constants.hx +++ b/source/funkin/util/Constants.hx @@ -88,9 +88,9 @@ class Constants public static final COLOR_HEALTH_BAR_GREEN:FlxColor = 0xFF66FF33; /** - * Default variation for charts. + * The base colors of the notes. */ - public static final DEFAULT_VARIATION:String = 'default'; + public static final COLOR_NOTES:Array = [0xFFFF22AA, 0xFF00EEFF, 0xFF00CC00, 0xFFCC1111]; /** * STAGE DEFAULTS @@ -117,6 +117,11 @@ class Constants */ public static final DEFAULT_SONG:String = 'tutorial'; + /** + * Default variation for charts. + */ + public static final DEFAULT_VARIATION:String = 'default'; + /** * OTHER */ @@ -144,6 +149,9 @@ class Constants */ public static final COUNTDOWN_VOLUME:Float = 0.6; + public static final STRUMLINE_X_OFFSET:Float = 48; + public static final STRUMLINE_Y_OFFSET:Float = 24; + /** * The default intensity for camera zooms. */ diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx index 60b522744..649923275 100644 --- a/source/funkin/util/SortUtil.hx +++ b/source/funkin/util/SortUtil.hx @@ -4,6 +4,7 @@ package funkin.util; import flixel.FlxBasic; import flixel.util.FlxSort; #end +import funkin.play.notes.NoteSprite; class SortUtil { @@ -22,8 +23,8 @@ class SortUtil * * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` */ - public static inline function byStrumtime(order:Int, a:Note, b:Note) + public static inline function byStrumtime(order:Int, a:NoteSprite, b:NoteSprite) { - return FlxSort.byValues(order, a.data.strumTime, b.data.strumTime); + return FlxSort.byValues(order, a.noteData.time, b.noteData.time); } } diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx index f2f1dcf0a..42930570f 100644 --- a/source/funkin/util/WindowUtil.hx +++ b/source/funkin/util/WindowUtil.hx @@ -51,4 +51,13 @@ class WindowUtil // Do nothing. #end } + + /** + * Sets the title of the application window. + * @param value The title to use. + */ + public static function setWindowTitle(value:String):Void + { + lime.app.Application.current.window.title = value; + } } diff --git a/source/funkin/util/tools/ArraySortTools.hx b/source/funkin/util/tools/ArraySortTools.hx new file mode 100644 index 000000000..3af114b98 --- /dev/null +++ b/source/funkin/util/tools/ArraySortTools.hx @@ -0,0 +1,154 @@ +package funkin.util.tools; + +/** + * Contains code for sorting arrays using various algorithms. + * @see https://algs4.cs.princeton.edu/20sorting/ + */ +class ArraySortTools +{ + /** + * Sorts the input array using the merge sort algorithm. + * Stable and guaranteed to run in linearithmic time `O(n log n)`, + * but less efficient in "best-case" situations. + * + * @param input The array to sort in-place. + * @param compare The comparison function to use. + */ + public static function mergeSort(input:Array, compare:CompareFunction):Void + { + if (input == null || input.length <= 1) return; + if (compare == null) throw 'No comparison function provided.'; + + // Haxe implements merge sort by default. + haxe.ds.ArraySort.sort(input, compare); + } + + /** + * Sorts the input array using the quick sort algorithm. + * More efficient on smaller arrays, but is inefficient `O(n^2)` in "worst-case" situations. + * Not stable; relative order of equal elements is not preserved. + * + * @see https://stackoverflow.com/questions/33884057/quick-sort-stackoverflow-error-for-large-arrays + * Fix for stack overflow issues. + * @param input The array to sort in-place. + * @param compare The comparison function to use. + */ + public static function quickSort(input:Array, compare:CompareFunction):Void + { + if (input == null || input.length <= 1) return; + if (compare == null) throw 'No comparison function provided.'; + + quickSortInner(input, 0, input.length - 1, compare); + } + + /** + * Internal recursive function for the quick sort algorithm. + * Written with ChatGPT! + */ + static function quickSortInner(input:Array, low:Int, high:Int, compare:CompareFunction):Void + { + // When low == high, the array is empty or too small to sort. + + // EDIT: Recurse on the smaller partition, and loop for the larger partition. + while (low < high) + { + // Designate the first element in the array as the pivot, then partition the array around it. + // Elements less than the pivot will be to the left, and elements greater than the pivot will be to the right. + // Return the index of the pivot. + var pivot:Int = quickSortPartition(input, low, high, compare); + + if ((pivot) - low <= high - (pivot + 1)) + { + quickSortInner(input, low, pivot, compare); + low = pivot + 1; + } + else + { + quickSortInner(input, pivot + 1, high, compare); + high = pivot; + } + } + } + + /** + * Internal function for sorting a partition of an array in the quick sort algorithm. + * Written with ChatGPT! + */ + static function quickSortPartition(input:Array, low:Int, high:Int, compare:CompareFunction):Int + { + // Designate the first element in the array as the pivot. + var pivot:T = input[low]; + // Designate two pointers, used to divide the array into two partitions. + var i:Int = low - 1; + var j:Int = high + 1; + + while (true) + { + // Move the left pointer to the right until it finds an element greater than the pivot. + do + { + i++; + } + while (compare(input[i], pivot) < 0); + + // Move the right pointer to the left until it finds an element less than the pivot. + do + { + j--; + } + while (compare(input[j], pivot) > 0); + + // If i and j have crossed, the array has been partitioned, and the pivot will be at the index j. + if (i >= j) return j; + + // Else, swap the elements at i and j, and start over. + // This slowly moves the pivot towards the middle of the partition, + // while moving elements less than the pivot to the left and elements greater than the pivot to the right. + var temp:T = input[i]; + input[i] = input[j]; + input[j] = temp; + } + } + + /** + * Sorts the input array using the insertion sort algorithm. + * Stable and is very fast on nearly-sorted arrays, + * but is inefficient `O(n^2)` in "worst-case" situations. + * + * @param input The array to sort in-place. + * @param compare The comparison function to use. + */ + public static function insertionSort(input:Array, compare:CompareFunction):Void + { + if (input == null || input.length <= 1) return; + if (compare == null) throw 'No comparison function provided.'; + + // Iterate through the array, starting at the second element. + for (i in 1...input.length) + { + // Store the current element. + var current:T = input[i]; + // Store the index of the previous element. + var j:Int = i - 1; + + // While the previous element is greater than the current element, + // move the previous element to the right and move the index to the left. + while (j >= 0 && compare(input[j], current) > 0) + { + input[j + 1] = input[j]; + j--; + } + + // Insert the current element into the array. + input[j + 1] = current; + } + } +} + +/** + * A comparison function. + * Returns a negative number if the first argument is less than the second, + * a positive number if the first argument is greater than the second, + * or zero if the two arguments are equal. + */ +typedef CompareFunction = T->T->Int; diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx index 02671a8e8..c27f1bf43 100644 --- a/source/funkin/util/tools/ArrayTools.hx +++ b/source/funkin/util/tools/ArrayTools.hx @@ -22,4 +22,19 @@ class ArrayTools } return result; } + + /** + * Return the first element of the array that satisfies the predicate, or null if none do. + * @param input The array to search + * @param predicate The predicate to call + * @return The result + */ + public static function find(input:Array, predicate:T->Bool):Null + { + for (element in input) + { + if (predicate(element)) return element; + } + return null; + } }