diff --git a/Project.xml b/Project.xml index b8f5a0c88..a251ccdbf 100644 --- a/Project.xml +++ b/Project.xml @@ -151,16 +151,23 @@ + + + - - - - + + + + + diff --git a/hxformat.json b/hxformat.json index 3eeb6de92..2a7775dda 100644 --- a/hxformat.json +++ b/hxformat.json @@ -2,11 +2,14 @@ "lineEnds": { "leftCurly": "both", "rightCurly": "both", - "emptyCurly": "break", + "emptyCurly": "noBreak", "objectLiteralCurly": { "leftCurly": "after" } }, + "indentation": { + "character": " " + }, "sameLine": { "ifElse": "next", "doWhile": "next", diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index 425ce25ae..8fb057ae9 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -7,297 +7,295 @@ import funkin.play.song.SongData.SongTimeChange; typedef BPMChangeEvent = { - var stepTime:Int; - var songTime:Float; - var bpm:Float; + var stepTime:Int; + var songTime:Float; + var bpm:Float; } class Conductor { - /** - * The list of time changes in the song. - * There should be at least one time change (at the beginning of the song) to define the BPM. - */ - private static var timeChanges:Array = []; + /** + * The list of time changes in the song. + * There should be at least one time change (at the beginning of the song) to define the BPM. + */ + private static var timeChanges:Array = []; - /** - * The current time change. - */ - private static var currentTimeChange:SongTimeChange; + /** + * The current time change. + */ + private static var currentTimeChange:SongTimeChange; - /** - * The current position in the song in milliseconds. - * Updated every frame based on the audio position. - */ - public static var songPosition:Float; + /** + * The current position in the song in milliseconds. + * Updated every frame based on the audio position. + */ + public static var songPosition:Float; - /** - * Beats per minute of the current song at the current time. - */ - public static var bpm(get, null):Float; + /** + * Beats per minute of the current song at the current time. + */ + public static var bpm(get, null):Float; - static function get_bpm():Float - { - if (bpmOverride != null) - return bpmOverride; + static function get_bpm():Float + { + if (bpmOverride != null) + return bpmOverride; - if (currentTimeChange == null) - return 100; + if (currentTimeChange == null) + return 100; - return currentTimeChange.bpm; - } + return currentTimeChange.bpm; + } - static var bpmOverride:Null = null; + static var bpmOverride:Null = null; - // OLD, replaced with timeChanges. - public static var bpmChangeMap:Array = []; + // OLD, replaced with timeChanges. + public static var bpmChangeMap:Array = []; - /** - * Duration of a beat in millisecond. Calculated based on bpm. - */ - public static var crochet(get, null):Float; + /** + * Duration of a beat in millisecond. Calculated based on bpm. + */ + public static var crochet(get, null):Float; - static function get_crochet():Float - { - return ((60 / bpm) * 1000); - } + static function get_crochet():Float + { + return ((60 / bpm) * 1000); + } - /** - * Duration of a step (quarter) in milliseconds. Calculated based on bpm. - */ - public static var stepCrochet(get, null):Float; + /** + * Duration of a step (quarter) in milliseconds. Calculated based on bpm. + */ + public static var stepCrochet(get, null):Float; - static function get_stepCrochet():Float - { - return crochet / timeSignatureNumerator; - } + static function get_stepCrochet():Float + { + return crochet / timeSignatureNumerator; + } - public static var timeSignatureNumerator(get, null):Int; + public static var timeSignatureNumerator(get, null):Int; - static function get_timeSignatureNumerator():Int - { - if (currentTimeChange == null) - return 4; + static function get_timeSignatureNumerator():Int + { + if (currentTimeChange == null) + return 4; - return currentTimeChange.timeSignatureNum; - } + return currentTimeChange.timeSignatureNum; + } - public static var timeSignatureDenominator(get, null):Int; + public static var timeSignatureDenominator(get, null):Int; - static function get_timeSignatureDenominator():Int - { - if (currentTimeChange == null) - return 4; + static function get_timeSignatureDenominator():Int + { + if (currentTimeChange == null) + return 4; - return currentTimeChange.timeSignatureDen; - } + return currentTimeChange.timeSignatureDen; + } - /** - * Current position in the song, in beats. - **/ - public static var currentBeat(default, null):Int; + /** + * Current position in the song, in beats. + **/ + public static var currentBeat(default, null):Int; - /** - * Current position in the song, in steps. - */ - public static var currentStep(default, null):Int; + /** + * Current position in the song, in steps. + */ + public static var currentStep(default, null):Int; - /** - * Current position in the song, in steps and fractions of a step. - */ - public static var currentStepTime(default, null):Float; + /** + * Current position in the song, in steps and fractions of a step. + */ + public static var currentStepTime(default, null):Float; - public static var beatHit(default, null):FlxSignal = new FlxSignal(); - public static var stepHit(default, null):FlxSignal = new FlxSignal(); + public static var beatHit(default, null):FlxSignal = new FlxSignal(); + public static var stepHit(default, null):FlxSignal = new FlxSignal(); - public static var lastSongPos:Float; - public static var visualOffset:Float = 0; - public static var audioOffset:Float = 0; - public static var offset:Float = 0; + public static var lastSongPos:Float; + public static var visualOffset:Float = 0; + public static var audioOffset:Float = 0; + public static var offset:Float = 0; - // TODO: Add code to update this. - public static var beatsPerMeasure(get, null):Int; + // TODO: Add code to update this. + public static var beatsPerMeasure(get, null):Int; - static function get_beatsPerMeasure():Int - { - return timeSignatureNumerator; - } + static function get_beatsPerMeasure():Int + { + return timeSignatureNumerator; + } - public static var stepsPerMeasure(get, null):Int; + public static var stepsPerMeasure(get, null):Int; - static function get_stepsPerMeasure():Int - { - // Is this always x4? - return timeSignatureNumerator * 4; - } + static function get_stepsPerMeasure():Int + { + // Is this always x4? + return timeSignatureNumerator * 4; + } - private function new() - { - } + private function new() {} - public static function getLastBPMChange() - { - var lastChange:BPMChangeEvent = { - stepTime: 0, - songTime: 0, - bpm: 0 - } - for (i in 0...Conductor.bpmChangeMap.length) - { - if (Conductor.songPosition >= Conductor.bpmChangeMap[i].songTime) - lastChange = Conductor.bpmChangeMap[i]; + public static function getLastBPMChange() + { + var lastChange:BPMChangeEvent = { + stepTime: 0, + songTime: 0, + bpm: 0 + } + for (i in 0...Conductor.bpmChangeMap.length) + { + if (Conductor.songPosition >= Conductor.bpmChangeMap[i].songTime) + lastChange = Conductor.bpmChangeMap[i]; - if (Conductor.songPosition < Conductor.bpmChangeMap[i].songTime) - break; - } - return lastChange; - } + if (Conductor.songPosition < Conductor.bpmChangeMap[i].songTime) + break; + } + return lastChange; + } - /** - * Forcibly defines the current BPM of the song. - * Useful for things like the chart editor that need to manipulate BPM in real time. - * - * Set to null to reset to the BPM defined by the timeChanges. - * - * WARNING: Avoid this for things like setting the BPM of the title screen music, - * you should have a metadata file for it instead. - */ - public static function forceBPM(?bpm:Float = null) - { - if (bpm != null) - trace('[CONDUCTOR] Forcing BPM to ' + bpm); - else - trace('[CONDUCTOR] Resetting BPM to default'); - Conductor.bpmOverride = bpm; - } + /** + * Forcibly defines the current BPM of the song. + * Useful for things like the chart editor that need to manipulate BPM in real time. + * + * Set to null to reset to the BPM defined by the timeChanges. + * + * WARNING: Avoid this for things like setting the BPM of the title screen music, + * you should have a metadata file for it instead. + */ + public static function forceBPM(?bpm:Float = null) + { + if (bpm != null) + trace('[CONDUCTOR] Forcing BPM to ' + bpm); + else + trace('[CONDUCTOR] Resetting BPM to default'); + Conductor.bpmOverride = bpm; + } - /** - * Update the conductor with the current song position. - * BPM, current step, etc. will be re-calculated based on the song position. - * - * @param songPosition The current position in the song in milliseconds. - * Leave blank to use the FlxG.sound.music position. - */ - public static function update(songPosition:Float = null) - { - if (songPosition == null) - songPosition = (FlxG.sound.music != null) ? (FlxG.sound.music.time + Conductor.offset) : 0; + /** + * Update the conductor with the current song position. + * BPM, current step, etc. will be re-calculated based on the song position. + * + * @param songPosition The current position in the song in milliseconds. + * Leave blank to use the FlxG.sound.music position. + */ + public static function update(songPosition:Float = null) + { + if (songPosition == null) + songPosition = (FlxG.sound.music != null) ? FlxG.sound.music.time + Conductor.offset : 0.0; - var oldBeat = currentBeat; - var oldStep = currentStep; + var oldBeat = currentBeat; + var oldStep = currentStep; - Conductor.songPosition = songPosition; - // Conductor.bpm = Conductor.getLastBPMChange().bpm; + Conductor.songPosition = songPosition; + // Conductor.bpm = Conductor.getLastBPMChange().bpm; - currentTimeChange = timeChanges[0]; - for (i in 0...timeChanges.length) - { - if (songPosition >= timeChanges[i].timeStamp) - currentTimeChange = timeChanges[i]; + currentTimeChange = timeChanges[0]; + for (i in 0...timeChanges.length) + { + if (songPosition >= timeChanges[i].timeStamp) + currentTimeChange = timeChanges[i]; - if (songPosition < timeChanges[i].timeStamp) - break; - } + if (songPosition < timeChanges[i].timeStamp) + break; + } - if (currentTimeChange == null && bpmOverride == null) - { - trace('WARNING: Conductor is broken, timeChanges is empty.'); - } - else if (currentTimeChange != null) - { - currentStepTime = (currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepCrochet; - currentStep = Math.floor(currentStepTime); - currentBeat = Math.floor(currentStep / 4); - } - else - { - // Assume a constant BPM equal to the forced value. - currentStepTime = (songPosition / stepCrochet); - currentStep = Math.floor(currentStepTime); - currentBeat = Math.floor(currentStep / 4); - } + if (currentTimeChange == null && bpmOverride == null && FlxG.sound.music != null) + { + trace('WARNING: Conductor is broken, timeChanges is empty.'); + } + else if (currentTimeChange != null) + { + currentStepTime = (currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepCrochet; + currentStep = Math.floor(currentStepTime); + currentBeat = Math.floor(currentStep / 4); + } + else + { + // Assume a constant BPM equal to the forced value. + currentStepTime = (songPosition / stepCrochet); + currentStep = Math.floor(currentStepTime); + currentBeat = Math.floor(currentStep / 4); + } - // FlxSignals are really cool. - if (currentStep != oldStep) - stepHit.dispatch(); + // FlxSignals are really cool. + if (currentStep != oldStep) + stepHit.dispatch(); - if (currentBeat != oldBeat) - beatHit.dispatch(); - } + if (currentBeat != oldBeat) + beatHit.dispatch(); + } - @:deprecated // Switch to TimeChanges instead. - public static function mapBPMChanges(song:SwagSong) - { - bpmChangeMap = []; + @:deprecated // Switch to TimeChanges instead. + public static function mapBPMChanges(song:SwagSong) + { + bpmChangeMap = []; - var curBPM:Float = song.bpm; - var totalSteps:Int = 0; - var totalPos:Float = 0; - for (i in 0...SongLoad.getSong().length) - { - if (SongLoad.getSong()[i].changeBPM && SongLoad.getSong()[i].bpm != curBPM) - { - curBPM = SongLoad.getSong()[i].bpm; - var event:BPMChangeEvent = { - stepTime: totalSteps, - songTime: totalPos, - bpm: curBPM - }; - bpmChangeMap.push(event); - } + var curBPM:Float = song.bpm; + var totalSteps:Int = 0; + var totalPos:Float = 0; + for (i in 0...SongLoad.getSong().length) + { + if (SongLoad.getSong()[i].changeBPM && SongLoad.getSong()[i].bpm != curBPM) + { + curBPM = SongLoad.getSong()[i].bpm; + var event:BPMChangeEvent = { + stepTime: totalSteps, + songTime: totalPos, + bpm: curBPM + }; + bpmChangeMap.push(event); + } - var deltaSteps:Int = SongLoad.getSong()[i].lengthInSteps; - totalSteps += deltaSteps; - totalPos += ((60 / curBPM) * 1000 / 4) * deltaSteps; - } - } + var deltaSteps:Int = SongLoad.getSong()[i].lengthInSteps; + totalSteps += deltaSteps; + totalPos += ((60 / curBPM) * 1000 / 4) * deltaSteps; + } + } - public static function mapTimeChanges(songTimeChanges:Array) - { - timeChanges = []; + public static function mapTimeChanges(songTimeChanges:Array) + { + timeChanges = []; - for (currentTimeChange in songTimeChanges) - { - timeChanges.push(currentTimeChange); - } + for (currentTimeChange in songTimeChanges) + { + timeChanges.push(currentTimeChange); + } - trace('Done mapping time changes: ' + timeChanges); + trace('Done mapping time changes: ' + timeChanges); - // Done. - } + // Done. + } - /** - * Given a time in milliseconds, return a time in steps. - */ - public static function getTimeInSteps(ms:Float):Int - { - if (timeChanges.length == 0) - { - // Assume a constant BPM equal to the forced value. - return Math.floor(ms / stepCrochet); - } - else - { - var resultStep:Int = 0; + /** + * Given a time in milliseconds, return a time in steps. + */ + public static function getTimeInSteps(ms:Float):Int + { + if (timeChanges.length == 0) + { + // Assume a constant BPM equal to the forced value. + return Math.floor(ms / stepCrochet); + } + else + { + var resultStep:Int = 0; - var lastTimeChange:SongTimeChange = timeChanges[0]; - for (timeChange in timeChanges) - { - if (ms >= timeChange.timeStamp) - { - lastTimeChange = timeChange; - resultStep = lastTimeChange.beatTime * 4; - } - else - { - // This time change is after the requested time. - break; - } - } + var lastTimeChange:SongTimeChange = timeChanges[0]; + for (timeChange in timeChanges) + { + if (ms >= timeChange.timeStamp) + { + lastTimeChange = timeChange; + resultStep = lastTimeChange.beatTime * 4; + } + else + { + // This time change is after the requested time. + break; + } + } - resultStep += Math.floor((ms - lastTimeChange.timeStamp) / stepCrochet); + resultStep += Math.floor((ms - lastTimeChange.timeStamp) / stepCrochet); - return resultStep; - } - } + return resultStep; + } + } } diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx index 60d8f71c6..664613b1c 100644 --- a/source/funkin/FreeplayState.hx +++ b/source/funkin/FreeplayState.hx @@ -38,905 +38,906 @@ import lime.utils.Assets; class FreeplayState extends MusicBeatSubstate { - var songs:Array = []; + var songs:Array = []; - // var selector:FlxText; - var curSelected:Int = 0; - var curDifficulty:Int = 1; + // var selector:FlxText; + var curSelected:Int = 0; + var curDifficulty:Int = 1; - var fp:FreeplayScore; - var txtCompletion:FlxText; - var lerpCompletion:Float = 0; - var intendedCompletion:Float = 0; - var lerpScore:Float = 0; - var intendedScore:Int = 0; + var fp:FreeplayScore; + var txtCompletion:FlxText; + var lerpCompletion:Float = 0; + var intendedCompletion:Float = 0; + var lerpScore:Float = 0; + var intendedScore:Int = 0; - var grpDifficulties:FlxSpriteGroup; + var grpDifficulties:FlxSpriteGroup; - var coolColors:Array = [ - 0xff9271fd, - 0xff9271fd, - 0xff223344, - 0xFF941653, - 0xFFfc96d7, - 0xFFa0d1ff, - 0xffff78bf, - 0xfff6b604 - ]; + var coolColors:Array = [ + 0xff9271fd, + 0xff9271fd, + 0xff223344, + 0xFF941653, + 0xFFfc96d7, + 0xFFa0d1ff, + 0xffff78bf, + 0xfff6b604 + ]; - private var grpSongs:FlxTypedGroup; - private var grpCapsules:FlxTypedGroup; - private var curPlaying:Bool = false; + private var grpSongs:FlxTypedGroup; + private var grpCapsules:FlxTypedGroup; + private var curPlaying:Bool = false; - private var dj:DJBoyfriend; + private var dj:DJBoyfriend; - private var iconArray:Array = []; + private var iconArray:Array = []; - var typing:FlxInputText; + var typing:FlxInputText; - override function create() - { - FlxTransitionableState.skipNextTransIn = true; + override function create() + { + FlxTransitionableState.skipNextTransIn = true; - #if discord_rpc - // Updating Discord Rich Presence - DiscordClient.changePresence("In the Menus", null); - #end + #if discord_rpc + // Updating Discord Rich Presence + DiscordClient.changePresence("In the Menus", null); + #end - var isDebug:Bool = false; + var isDebug:Bool = false; - #if debug - isDebug = true; - addSong('Test', 1, 'bf-pixel'); - addSong('Pyro', 8, 'darnell'); - #end + #if debug + isDebug = true; + addSong('Test', 1, 'bf-pixel'); + addSong('Pyro', 8, 'darnell'); + #end - var initSonglist = CoolUtil.coolTextFile(Paths.txt('freeplaySonglist')); + var initSonglist = CoolUtil.coolTextFile(Paths.txt('freeplaySonglist')); - for (i in 0...initSonglist.length) - { - songs.push(new SongMetadata(initSonglist[i], 1, 'gf')); - } + for (i in 0...initSonglist.length) + { + songs.push(new SongMetadata(initSonglist[i], 1, 'gf')); + } - if (FlxG.sound.music != null) - { - if (!FlxG.sound.music.playing) - FlxG.sound.playMusic(Paths.music('freakyMenu')); - } + if (FlxG.sound.music != null) + { + if (!FlxG.sound.music.playing) + FlxG.sound.playMusic(Paths.music('freakyMenu')); + } - if (StoryMenuState.weekUnlocked[2] || isDebug) - addWeek(['Bopeebo', 'Fresh', 'Dadbattle'], 1, ['dad']); + if (StoryMenuState.weekUnlocked[2] || isDebug) + addWeek(['Bopeebo', 'Fresh', 'Dadbattle'], 1, ['dad']); - if (StoryMenuState.weekUnlocked[2] || isDebug) - addWeek(['Spookeez', 'South', 'Monster'], 2, ['spooky', 'spooky', 'monster']); + if (StoryMenuState.weekUnlocked[2] || isDebug) + addWeek(['Spookeez', 'South', 'Monster'], 2, ['spooky', 'spooky', 'monster']); - if (StoryMenuState.weekUnlocked[3] || isDebug) - addWeek(['Pico', 'Philly', 'Blammed'], 3, ['pico']); + if (StoryMenuState.weekUnlocked[3] || isDebug) + addWeek(['Pico', 'Philly', 'Blammed'], 3, ['pico']); - if (StoryMenuState.weekUnlocked[4] || isDebug) - addWeek(['Satin-Panties', 'High', 'Milf'], 4, ['mom']); + if (StoryMenuState.weekUnlocked[4] || isDebug) + addWeek(['Satin-Panties', 'High', 'Milf'], 4, ['mom']); - if (StoryMenuState.weekUnlocked[5] || isDebug) - addWeek(['Cocoa', 'Eggnog', 'Winter-Horrorland'], 5, ['parents-christmas', 'parents-christmas', 'monster-christmas']); + if (StoryMenuState.weekUnlocked[5] || isDebug) + addWeek(['Cocoa', 'Eggnog', 'Winter-Horrorland'], 5, ['parents-christmas', 'parents-christmas', 'monster-christmas']); - if (StoryMenuState.weekUnlocked[6] || isDebug) - addWeek(['Senpai', 'Roses', 'Thorns'], 6, ['senpai', 'senpai', 'spirit']); + if (StoryMenuState.weekUnlocked[6] || isDebug) + addWeek(['Senpai', 'Roses', 'Thorns'], 6, ['senpai', 'senpai', 'spirit']); - if (StoryMenuState.weekUnlocked[7] || isDebug) - addWeek(['Ugh', 'Guns', 'Stress'], 7, ['tankman']); + if (StoryMenuState.weekUnlocked[7] || isDebug) + addWeek(['Ugh', 'Guns', 'Stress'], 7, ['tankman']); - addWeek(["Darnell", "lit-up", "2hot", "blazin"], 8, ['darnell']); + addWeek(["Darnell", "lit-up", "2hot", "blazin"], 8, ['darnell']); - // LOAD MUSIC + // LOAD MUSIC - // LOAD CHARACTERS + // LOAD CHARACTERS - trace(FlxG.width); - trace(FlxG.camera.zoom); - trace(FlxG.camera.initialZoom); - trace(FlxCamera.defaultZoom); + trace(FlxG.width); + trace(FlxG.camera.zoom); + trace(FlxG.camera.initialZoom); + trace(FlxCamera.defaultZoom); - var pinkBack:FlxSprite = new FlxSprite().loadGraphic(Paths.image('freeplay/pinkBack')); - pinkBack.color = 0xFFffd4e9; // sets it to pink! - pinkBack.x -= pinkBack.width; + var pinkBack:FlxSprite = new FlxSprite().loadGraphic(Paths.image('freeplay/pinkBack')); + pinkBack.color = 0xFFffd4e9; // sets it to pink! + pinkBack.x -= pinkBack.width; - FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); - add(pinkBack); + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); - var orangeBackShit:FlxSprite = new FlxSprite(84, FlxG.height * 0.68).makeGraphic(Std.int(pinkBack.width), 50, 0xFFffd400); - add(orangeBackShit); + var orangeBackShit:FlxSprite = new FlxSprite(84, FlxG.height * 0.68).makeGraphic(Std.int(pinkBack.width), 50, 0xFFffd400); + add(orangeBackShit); - var alsoOrangeLOL:FlxSprite = new FlxSprite(0, orangeBackShit.y).makeGraphic(100, Std.int(orangeBackShit.height), 0xFFffd400); - add(alsoOrangeLOL); + var alsoOrangeLOL:FlxSprite = new FlxSprite(0, orangeBackShit.y).makeGraphic(100, Std.int(orangeBackShit.height), 0xFFffd400); + add(alsoOrangeLOL); - FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit); - orangeBackShit.visible = false; - alsoOrangeLOL.visible = false; + FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit); + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; - var grpTxtScrolls:FlxGroup = new FlxGroup(); - add(grpTxtScrolls); - grpTxtScrolls.visible = false; + var grpTxtScrolls:FlxGroup = new FlxGroup(); + add(grpTxtScrolls); + grpTxtScrolls.visible = false; - var moreWays:BGScrollingText = new BGScrollingText(0, 200, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width); - moreWays.funnyColor = 0xFFfff383; - moreWays.speed = 4; - grpTxtScrolls.add(moreWays); + var moreWays:BGScrollingText = new BGScrollingText(0, 200, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width); + moreWays.funnyColor = 0xFFfff383; + moreWays.speed = 4; + grpTxtScrolls.add(moreWays); - var funnyScroll:BGScrollingText = new BGScrollingText(0, 250, "BOYFRIEND", FlxG.width / 2); - funnyScroll.funnyColor = 0xFFff9963; - funnyScroll.speed = -1; - grpTxtScrolls.add(funnyScroll); + var funnyScroll:BGScrollingText = new BGScrollingText(0, 250, "BOYFRIEND", FlxG.width / 2); + funnyScroll.funnyColor = 0xFFff9963; + funnyScroll.speed = -1; + grpTxtScrolls.add(funnyScroll); - var txtNuts:BGScrollingText = new BGScrollingText(0, 300, "PROTECT YO NUTS", FlxG.width / 2); - grpTxtScrolls.add(txtNuts); + var txtNuts:BGScrollingText = new BGScrollingText(0, 300, "PROTECT YO NUTS", FlxG.width / 2); + grpTxtScrolls.add(txtNuts); - var funnyScroll2:BGScrollingText = new BGScrollingText(0, 340, "BOYFRIEND", FlxG.width / 2); - funnyScroll2.funnyColor = 0xFFff9963; - funnyScroll2.speed = -1.2; - grpTxtScrolls.add(funnyScroll2); + var funnyScroll2:BGScrollingText = new BGScrollingText(0, 340, "BOYFRIEND", FlxG.width / 2); + funnyScroll2.funnyColor = 0xFFff9963; + funnyScroll2.speed = -1.2; + grpTxtScrolls.add(funnyScroll2); - var moreWays2:BGScrollingText = new BGScrollingText(0, 400, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width); - moreWays2.funnyColor = 0xFFfff383; - moreWays2.speed = 4.4; - grpTxtScrolls.add(moreWays2); - - var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y, "BOYFRIEND", FlxG.width / 2); - funnyScroll3.funnyColor = 0xFFff9963; - funnyScroll3.speed = -0.8; - grpTxtScrolls.add(funnyScroll3); - - dj = new DJBoyfriend(0, -100); - add(dj); - - var bgDad:FlxSprite = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad')); - bgDad.setGraphicSize(0, FlxG.height); - bgDad.updateHitbox(); - bgDad.shader = new AngleMask(); - bgDad.visible = false; - - var blackOverlayBullshitLOLXD:FlxSprite = new FlxSprite(FlxG.width).makeGraphic(Std.int(bgDad.width), Std.int(bgDad.height), FlxColor.BLACK); - add(blackOverlayBullshitLOLXD); // used to mask the text lol! - - add(bgDad); - FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.75}, 1, {ease: FlxEase.quintOut}); - - blackOverlayBullshitLOLXD.shader = bgDad.shader; - - grpSongs = new FlxTypedGroup(); - add(grpSongs); - - grpCapsules = new FlxTypedGroup(); - add(grpCapsules); - - grpDifficulties = new FlxSpriteGroup(-300, 80); - add(grpDifficulties); - - grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayEasy'))); - grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayNorm'))); - grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayHard'))); - - grpDifficulties.group.forEach(function(spr) - { - spr.visible = false; - }); - - grpDifficulties.group.members[curDifficulty].visible = true; - - var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 64, FlxColor.BLACK); - overhangStuff.y -= overhangStuff.height; - add(overhangStuff); - FlxTween.tween(overhangStuff, {y: 0}, 0.3, {ease: FlxEase.quartOut}); - - var fnfFreeplay:FlxText = new FlxText(0, 12, 0, "FREEPLAY", 48); - fnfFreeplay.font = "VCR OSD Mono"; - fnfFreeplay.visible = false; - var sillyStroke = new StrokeShader(0xFFFFFFFF, 2, 2); - fnfFreeplay.shader = sillyStroke; - add(fnfFreeplay); - - var fnfHighscoreSpr:FlxSprite = new FlxSprite(890, 70); - fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore'); - fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false); - fnfHighscoreSpr.visible = false; - fnfHighscoreSpr.setGraphicSize(0, Std.int(fnfHighscoreSpr.height * 1)); - fnfHighscoreSpr.antialiasing = true; - fnfHighscoreSpr.updateHitbox(); - add(fnfHighscoreSpr); - - new FlxTimer().start(FlxG.random.float(12, 50), function(tmr) - { - fnfHighscoreSpr.animation.play("highscore"); - tmr.time = FlxG.random.float(20, 60); - }, 0); - - fp = new FreeplayScore(460, 60, 100); - fp.visible = false; - add(fp); - - txtCompletion = new FlxText(1200, 77, 0, "0", 32); - txtCompletion.font = "VCR OSD Mono"; - txtCompletion.visible = false; - add(txtCompletion); - - dj.onIntroDone.add(function() - { - FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut}); - - add(new DifficultySelector(20, grpDifficulties.y - 10, false, controls)); - add(new DifficultySelector(325, grpDifficulties.y - 10, true, controls)); - - var letterSort:LetterSort = new LetterSort(300, 100); - add(letterSort); - - letterSort.changeSelectionCallback = (str) -> - { - switch (str) - { - case "fav": - generateSongList({filterType: FAVORITE}, true); - case "ALL": - generateSongList(null, true); - default: - generateSongList({filterType: STARTSWITH, filterData: str}, true); - } - }; - - new FlxTimer().start(1 / 24, function(handShit) - { - fnfHighscoreSpr.visible = true; - fnfFreeplay.visible = true; - fp.visible = true; - fp.updateScore(0); - - txtCompletion.visible = true; - intendedCompletion = 0; - - new FlxTimer().start(1.5 / 24, function(bold) - { - sillyStroke.width = 0; - sillyStroke.height = 0; - }); - }); - - pinkBack.color = 0xFFffd863; - // fnfFreeplay.visible = true; - bgDad.visible = true; - orangeBackShit.visible = true; - alsoOrangeLOL.visible = true; - grpTxtScrolls.visible = true; - }); - - generateSongList(); - - // FlxG.sound.playMusic(Paths.music('title'), 0); - // FlxG.sound.music.fadeIn(2, 0, 0.8); - // selector = new FlxText(); - - // selector.size = 40; - // selector.text = ">"; - // add(selector); - - var swag:Alphabet = new Alphabet(1, 0, "swag"); - - // JUST DOIN THIS SHIT FOR TESTING!!! - /* - var md:String = Markdown.markdownToHtml(Assets.getText('CHANGELOG.md')); - - var texFel:TextField = new TextField(); - texFel.width = FlxG.width; - texFel.height = FlxG.height; - // texFel. - texFel.htmlText = md; - - FlxG.stage.addChild(texFel); - - trace(md); - */ - - var funnyCam = new FlxCamera(0, 0, FlxG.width, FlxG.height); - funnyCam.bgColor = FlxColor.TRANSPARENT; - FlxG.cameras.add(funnyCam); - - typing = new FlxInputText(100, 100); - add(typing); - - typing.callback = function(txt, action) - { - // generateSongList(new EReg(txt.trim(), "ig")); - trace(action); - }; - - forEach(function(bs) - { - bs.cameras = [funnyCam]; - }); - - super.create(); - } - - public function generateSongList(?filterStuff:SongFilter, ?force:Bool = false) - { - curSelected = 0; - - grpCapsules.clear(); - - // var regexp:EReg = regexp; - var tempSongs:Array = songs; - - if (filterStuff != null) - { - switch (filterStuff.filterType) - { - case STARTSWITH: - tempSongs = tempSongs.filter(str -> - { - return str.songName.toLowerCase().startsWith(filterStuff.filterData); - }); - case ALL: - // no filter! - case FAVORITE: - tempSongs = tempSongs.filter(str -> - { - return str.isFav; - }); - default: - // return all on default - } - } - - // if (regexp != null) - // tempSongs = songs.filter(item -> regexp.match(item.songName)); - - // tempSongs.sort(function(a, b):Int - // { - // var tempA = a.songName.toUpperCase(); - // var tempB = b.songName.toUpperCase(); - - // if (tempA < tempB) - // return -1; - // else if (tempA > tempB) - // return 1; - // else - // return 0; - // }); - - for (i in 0...tempSongs.length) - { - var funnyMenu:SongMenuItem = new SongMenuItem(FlxG.width, (i * 150) + 160, tempSongs[i].songName); - funnyMenu.targetPos.x = funnyMenu.x; - funnyMenu.ID = i; - funnyMenu.alpha = 0.5; - funnyMenu.songText.visible = false; - funnyMenu.favIcon.visible = tempSongs[i].isFav; - - // fp.updateScore(0); - - new FlxTimer().start((1 / 24) * i, function(doShit) - { - funnyMenu.doJumpIn = true; - }); - - new FlxTimer().start((0.09 * i) + 0.85, function(lerpTmr) - { - funnyMenu.doLerp = true; - }); - - if (!force) - { - new FlxTimer().start(((0.20 * i) / (1 + i)) + 0.75, function(swagShi) - { - funnyMenu.songText.visible = true; - funnyMenu.alpha = 1; - }); - } - else - { - funnyMenu.songText.visible = true; - funnyMenu.alpha = 1; - } - - grpCapsules.add(funnyMenu); - - var songText:Alphabet = new Alphabet(0, (70 * i) + 30, tempSongs[i].songName, true, false); - songText.x += 100; - songText.isMenuItem = true; - songText.targetY = i; - - // grpSongs.add(songText); - - var icon:HealthIcon = new HealthIcon(tempSongs[i].songCharacter); - // icon.sprTracker = songText; - - // using a FlxGroup is too much fuss! - iconArray.push(icon); - // add(icon); - - // songText.x += 40; - // DONT PUT X IN THE FIRST PARAMETER OF new ALPHABET() !! - // songText.screenCenter(X); - } - - changeSelection(); - changeDiff(); - } - - public function addSong(songName:String, weekNum:Int, songCharacter:String) - { - songs.push(new SongMetadata(songName, weekNum, songCharacter)); - } - - public function addWeek(songs:Array, weekNum:Int, ?songCharacters:Array) - { - if (songCharacters == null) - songCharacters = ['bf']; - - var num:Int = 0; - for (song in songs) - { - addSong(song, weekNum, songCharacters[num]); - - if (songCharacters.length != 1) - num++; - } - } - - var touchY:Float = 0; - var touchX:Float = 0; - var dxTouch:Float = 0; - var dyTouch:Float = 0; - var velTouch:Float = 0; - - var veloctiyLoopShit:Float = 0; - var touchTimer:Float = 0; - - var initTouchPos:FlxPoint = new FlxPoint(); - - var spamTimer:Float = 0; - var spamming:Bool = false; - - override function update(elapsed:Float) - { - super.update(elapsed); - - if (FlxG.keys.justPressed.F) - { - var realShit = curSelected; - songs[curSelected].isFav = !songs[curSelected].isFav; - if (songs[curSelected].isFav) - { - FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4, { - ease: FlxEase.elasticOut, - onComplete: _ -> - { - grpCapsules.members[realShit].favIcon.visible = true; - grpCapsules.members[realShit].favIcon.animation.play("fav"); - } - }); - } - else - { - grpCapsules.members[realShit].favIcon.animation.play('fav', false, true); - new FlxTimer().start((1 / 24) * 14, _ -> - { - grpCapsules.members[realShit].favIcon.visible = false; - }); - new FlxTimer().start((1 / 24) * 24, _ -> - { - FlxTween.tween(grpCapsules.members[realShit], {angle: 0}, 0.4, {ease: FlxEase.elasticOut}); - }); - } - } - - if (FlxG.keys.justPressed.T) - typing.hasFocus = true; - - if (FlxG.sound.music != null) - { - if (FlxG.sound.music.volume < 0.7) - { - FlxG.sound.music.volume += 0.5 * elapsed; - } - } - - lerpScore = CoolUtil.coolLerp(lerpScore, intendedScore, 0.2); - lerpCompletion = CoolUtil.coolLerp(lerpCompletion, intendedCompletion, 0.9); - - fp.updateScore(Std.int(lerpScore)); - - txtCompletion.text = Math.floor(lerpCompletion * 100) + "%"; - trace(Highscore.getCompletion(songs[curSelected].songName, curDifficulty)); - - // trace(intendedScore); - // trace(lerpScore); - // Highscore.getAllScores(); - - var upP = controls.UI_UP_P; - var downP = controls.UI_DOWN_P; - var accepted = controls.ACCEPT; - - if (FlxG.onMobile) - { - for (touch in FlxG.touches.list) - { - if (touch.justPressed) - { - initTouchPos.set(touch.screenX, touch.screenY); - } - if (touch.pressed) - { - var dx = initTouchPos.x - touch.screenX; - var dy = initTouchPos.y - touch.screenY; - - var angle = Math.atan2(dy, dx); - var length = Math.sqrt(dx * dx + dy * dy); - - FlxG.watch.addQuick("LENGTH", length); - FlxG.watch.addQuick("ANGLE", Math.round(FlxAngle.asDegrees(angle))); - trace("ANGLE", Math.round(FlxAngle.asDegrees(angle))); - } - - /* switch (inputID) - { - case FlxObject.UP: - return - case FlxObject.DOWN: - } - */ - } - - if (FlxG.touches.getFirst() != null) - { - if (touchTimer >= 1.5) - accepted = true; - - touchTimer += elapsed; - var touch:FlxTouch = FlxG.touches.getFirst(); - - velTouch = Math.abs((touch.screenY - dyTouch)) / 50; - - dyTouch = touch.screenY - touchY; - dxTouch = touch.screenX - touchX; - - if (touch.justPressed) - { - touchY = touch.screenY; - dyTouch = 0; - velTouch = 0; - - touchX = touch.screenX; - dxTouch = 0; - } - - if (Math.abs(dxTouch) >= 100) - { - touchX = touch.screenX; - if (dxTouch != 0) - dxTouch < 0 ? changeDiff(1) : changeDiff(-1); - } - - if (Math.abs(dyTouch) >= 100) - { - touchY = touch.screenY; - - if (dyTouch != 0) - dyTouch < 0 ? changeSelection(1) : changeSelection(-1); - // changeSelection(1); - } - } - else - { - touchTimer = 0; - } - } - - #if mobile - for (touch in FlxG.touches.list) - { - if (touch.justPressed) - { - // accepted = true; - } - } - #end - - if (controls.UI_UP || controls.UI_DOWN) - { - spamTimer += elapsed; - - if (spamming) - { - if (spamTimer >= 0.07) - { - spamTimer = 0; - - if (controls.UI_UP) - changeSelection(-1); - else - changeSelection(1); - } - } - else if (spamTimer >= 0.9) - spamming = true; - } - else - { - spamming = false; - spamTimer = 0; - } - - if (upP) - { - dj.resetAFKTimer(); - changeSelection(-1); - } - if (downP) - { - dj.resetAFKTimer(); - changeSelection(1); - } - - if (FlxG.mouse.wheel != 0) - { - dj.resetAFKTimer(); - changeSelection(-Math.round(FlxG.mouse.wheel / 4)); - } - - if (controls.UI_LEFT_P) - { - dj.resetAFKTimer(); - changeDiff(-1); - } - if (controls.UI_RIGHT_P) - { - dj.resetAFKTimer(); - changeDiff(1); - } - - if (controls.BACK && !typing.hasFocus) - { - FlxG.sound.play(Paths.sound('cancelMenu')); - - FlxTransitionableState.skipNextTransIn = true; - FlxTransitionableState.skipNextTransOut = true; - FlxG.switchState(new MainMenuState()); - } - - if (accepted) - { - // if (Assets.exists()) - - var poop:String = songs[curSelected].songName.toLowerCase(); - - // does not work properly, always just accidentally sets it to normal anyways! - /* if (!Assets.exists(Paths.json(songs[curSelected].songName + '/' + poop))) - { - // defaults to normal if HARD / EASY doesn't exist - // does not account if NORMAL doesn't exist! - FlxG.log.warn("CURRENT DIFFICULTY IS NOT CHARTED, DEFAULTING TO NORMAL!"); - poop = Highscore.formatSong(songs[curSelected].songName.toLowerCase(), 1); - curDifficulty = 1; - }*/ - - PlayState.currentSong = SongLoad.loadFromJson(poop, songs[curSelected].songName.toLowerCase()); - PlayState.currentSong_NEW = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase()); - PlayState.isStoryMode = false; - PlayState.storyDifficulty = curDifficulty; - PlayState.storyDifficulty_NEW = switch (curDifficulty) - { - case 0: - 'easy'; - case 1: - 'normal'; - case 2: - 'hard'; - default: 'normal'; - }; - // SongLoad.curDiff = Highscore.formatSong() - - SongLoad.curDiff = PlayState.storyDifficulty_NEW; - - PlayState.storyWeek = songs[curSelected].week; - trace(' CUR WEEK ' + PlayState.storyWeek); - - // Visual and audio effects. - FlxG.sound.play(Paths.sound('confirmMenu')); - dj.confirm(); - - new FlxTimer().start(1, function(tmr:FlxTimer) - { - LoadingState.loadAndSwitchState(new PlayState(), true); - }); - } - } - - override function switchTo(nextState:FlxState):Bool - { - clearDaCache(songs[curSelected].songName); - return super.switchTo(nextState); - } - - function changeDiff(change:Int = 0) - { - touchTimer = 0; - - curDifficulty += change; - - if (curDifficulty < 0) - curDifficulty = 2; - if (curDifficulty > 2) - curDifficulty = 0; - - // intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty); - intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty); - intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty); - - PlayState.storyDifficulty = curDifficulty; - PlayState.storyDifficulty_NEW = switch (curDifficulty) - { - case 0: - 'easy'; - case 1: - 'normal'; - case 2: - 'hard'; - default: - 'normal'; - }; - - grpDifficulties.group.forEach(function(spr) - { - spr.visible = false; - }); - - var curShit:FlxSprite = grpDifficulties.group.members[curDifficulty]; - - curShit.visible = true; - curShit.offset.y += 5; - curShit.alpha = 0.5; - new FlxTimer().start(1 / 24, function(swag) - { - curShit.alpha = 1; - curShit.updateHitbox(); - }); - } - - // Clears the cache of songs, frees up memory, they' ll have to be loaded in later tho function clearDaCache(actualSongTho:String) - function clearDaCache(actualSongTho:String) - { - for (song in songs) - { - if (song.songName != actualSongTho) - { - trace('trying to remove: ' + song.songName); - // openfl.Assets.cache.clear(Paths.inst(song.songName)); - } - } - } - - function changeSelection(change:Int = 0) - { - // fp.updateScore(12345); - - NGio.logEvent('Fresh'); - - // NGio.logEvent('Fresh'); - FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); - - curSelected += change; - - if (curSelected < 0) - curSelected = grpCapsules.members.length - 1; - if (curSelected >= grpCapsules.members.length) - curSelected = 0; - - // selector.y = (70 * curSelected) + 30; - - // intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty); - intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty); - intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty); - // lerpScore = 0; - - #if PRELOAD_ALL - // FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName), 0); - #end - - var bullShit:Int = 0; - - for (i in 0...iconArray.length) - { - iconArray[i].alpha = 0.6; - } - - iconArray[curSelected].alpha = 1; - - for (index => capsule in grpCapsules.members) - { - capsule.selected = false; - - capsule.targetPos.y = ((index - curSelected) * 150) + 160; - capsule.targetPos.x = 270 + (60 * (Math.sin(index - curSelected))); - // capsule.targetPos.x = 320 + (40 * (index - curSelected)); - - if (index < curSelected) - capsule.targetPos.y -= 100; // another 100 for good measure - } - - if (grpCapsules.members.length > 0) - grpCapsules.members[curSelected].selected = true; - } + var moreWays2:BGScrollingText = new BGScrollingText(0, 400, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width); + moreWays2.funnyColor = 0xFFfff383; + moreWays2.speed = 4.4; + grpTxtScrolls.add(moreWays2); + + var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y, "BOYFRIEND", FlxG.width / 2); + funnyScroll3.funnyColor = 0xFFff9963; + funnyScroll3.speed = -0.8; + grpTxtScrolls.add(funnyScroll3); + + dj = new DJBoyfriend(0, -100); + add(dj); + + var bgDad:FlxSprite = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad')); + bgDad.setGraphicSize(0, FlxG.height); + bgDad.updateHitbox(); + bgDad.shader = new AngleMask(); + bgDad.visible = false; + + var blackOverlayBullshitLOLXD:FlxSprite = new FlxSprite(FlxG.width).makeGraphic(Std.int(bgDad.width), Std.int(bgDad.height), FlxColor.BLACK); + add(blackOverlayBullshitLOLXD); // used to mask the text lol! + + add(bgDad); + FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.75}, 1, {ease: FlxEase.quintOut}); + + blackOverlayBullshitLOLXD.shader = bgDad.shader; + + grpSongs = new FlxTypedGroup(); + add(grpSongs); + + grpCapsules = new FlxTypedGroup(); + add(grpCapsules); + + grpDifficulties = new FlxSpriteGroup(-300, 80); + add(grpDifficulties); + + grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayEasy'))); + grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayNorm'))); + grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayHard'))); + + grpDifficulties.group.forEach(function(spr) + { + spr.visible = false; + }); + + grpDifficulties.group.members[curDifficulty].visible = true; + + var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 64, FlxColor.BLACK); + overhangStuff.y -= overhangStuff.height; + add(overhangStuff); + FlxTween.tween(overhangStuff, {y: 0}, 0.3, {ease: FlxEase.quartOut}); + + var fnfFreeplay:FlxText = new FlxText(0, 12, 0, "FREEPLAY", 48); + fnfFreeplay.font = "VCR OSD Mono"; + fnfFreeplay.visible = false; + var sillyStroke = new StrokeShader(0xFFFFFFFF, 2, 2); + fnfFreeplay.shader = sillyStroke; + add(fnfFreeplay); + + var fnfHighscoreSpr:FlxSprite = new FlxSprite(890, 70); + fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore'); + fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false); + fnfHighscoreSpr.visible = false; + fnfHighscoreSpr.setGraphicSize(0, Std.int(fnfHighscoreSpr.height * 1)); + fnfHighscoreSpr.antialiasing = true; + fnfHighscoreSpr.updateHitbox(); + add(fnfHighscoreSpr); + + new FlxTimer().start(FlxG.random.float(12, 50), function(tmr) + { + fnfHighscoreSpr.animation.play("highscore"); + tmr.time = FlxG.random.float(20, 60); + }, 0); + + fp = new FreeplayScore(460, 60, 100); + fp.visible = false; + add(fp); + + txtCompletion = new FlxText(1200, 77, 0, "0", 32); + txtCompletion.font = "VCR OSD Mono"; + txtCompletion.visible = false; + add(txtCompletion); + + dj.onIntroDone.add(function() + { + FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut}); + + add(new DifficultySelector(20, grpDifficulties.y - 10, false, controls)); + add(new DifficultySelector(325, grpDifficulties.y - 10, true, controls)); + + var letterSort:LetterSort = new LetterSort(300, 100); + add(letterSort); + + letterSort.changeSelectionCallback = (str) -> + { + switch (str) + { + case "fav": + generateSongList({filterType: FAVORITE}, true); + case "ALL": + generateSongList(null, true); + default: + generateSongList({filterType: STARTSWITH, filterData: str}, true); + } + }; + + new FlxTimer().start(1 / 24, function(handShit) + { + fnfHighscoreSpr.visible = true; + fnfFreeplay.visible = true; + fp.visible = true; + fp.updateScore(0); + + txtCompletion.visible = true; + intendedCompletion = 0; + + new FlxTimer().start(1.5 / 24, function(bold) + { + sillyStroke.width = 0; + sillyStroke.height = 0; + }); + }); + + pinkBack.color = 0xFFffd863; + // fnfFreeplay.visible = true; + bgDad.visible = true; + orangeBackShit.visible = true; + alsoOrangeLOL.visible = true; + grpTxtScrolls.visible = true; + }); + + generateSongList(); + + // FlxG.sound.playMusic(Paths.music('title'), 0); + // FlxG.sound.music.fadeIn(2, 0, 0.8); + // selector = new FlxText(); + + // selector.size = 40; + // selector.text = ">"; + // add(selector); + + var swag:Alphabet = new Alphabet(1, 0, "swag"); + + // JUST DOIN THIS SHIT FOR TESTING!!! + /* + var md:String = Markdown.markdownToHtml(Assets.getText('CHANGELOG.md')); + + var texFel:TextField = new TextField(); + texFel.width = FlxG.width; + texFel.height = FlxG.height; + // texFel. + texFel.htmlText = md; + + FlxG.stage.addChild(texFel); + + trace(md); + */ + + var funnyCam = new FlxCamera(0, 0, FlxG.width, FlxG.height); + funnyCam.bgColor = FlxColor.TRANSPARENT; + FlxG.cameras.add(funnyCam); + + typing = new FlxInputText(100, 100); + add(typing); + + typing.callback = function(txt, action) + { + // generateSongList(new EReg(txt.trim(), "ig")); + trace(action); + }; + + forEach(function(bs) + { + bs.cameras = [funnyCam]; + }); + + super.create(); + } + + public function generateSongList(?filterStuff:SongFilter, ?force:Bool = false) + { + curSelected = 0; + + grpCapsules.clear(); + + // var regexp:EReg = regexp; + var tempSongs:Array = songs; + + if (filterStuff != null) + { + switch (filterStuff.filterType) + { + case STARTSWITH: + tempSongs = tempSongs.filter(str -> + { + return str.songName.toLowerCase().startsWith(filterStuff.filterData); + }); + case ALL: + // no filter! + case FAVORITE: + tempSongs = tempSongs.filter(str -> + { + return str.isFav; + }); + default: + // return all on default + } + } + + // if (regexp != null) + // tempSongs = songs.filter(item -> regexp.match(item.songName)); + + // tempSongs.sort(function(a, b):Int + // { + // var tempA = a.songName.toUpperCase(); + // var tempB = b.songName.toUpperCase(); + + // if (tempA < tempB) + // return -1; + // else if (tempA > tempB) + // return 1; + // else + // return 0; + // }); + + for (i in 0...tempSongs.length) + { + var funnyMenu:SongMenuItem = new SongMenuItem(FlxG.width, (i * 150) + 160, tempSongs[i].songName); + funnyMenu.targetPos.x = funnyMenu.x; + funnyMenu.ID = i; + funnyMenu.alpha = 0.5; + funnyMenu.songText.visible = false; + funnyMenu.favIcon.visible = tempSongs[i].isFav; + + // fp.updateScore(0); + + new FlxTimer().start((1 / 24) * i, function(doShit) + { + funnyMenu.doJumpIn = true; + }); + + new FlxTimer().start((0.09 * i) + 0.85, function(lerpTmr) + { + funnyMenu.doLerp = true; + }); + + if (!force) + { + new FlxTimer().start(((0.20 * i) / (1 + i)) + 0.75, function(swagShi) + { + funnyMenu.songText.visible = true; + funnyMenu.alpha = 1; + }); + } + else + { + funnyMenu.songText.visible = true; + funnyMenu.alpha = 1; + } + + grpCapsules.add(funnyMenu); + + var songText:Alphabet = new Alphabet(0, (70 * i) + 30, tempSongs[i].songName, true, false); + songText.x += 100; + songText.isMenuItem = true; + songText.targetY = i; + + // grpSongs.add(songText); + + var icon:HealthIcon = new HealthIcon(tempSongs[i].songCharacter); + // icon.sprTracker = songText; + + // using a FlxGroup is too much fuss! + iconArray.push(icon); + // add(icon); + + // songText.x += 40; + // DONT PUT X IN THE FIRST PARAMETER OF new ALPHABET() !! + // songText.screenCenter(X); + } + + changeSelection(); + changeDiff(); + } + + public function addSong(songName:String, weekNum:Int, songCharacter:String) + { + songs.push(new SongMetadata(songName, weekNum, songCharacter)); + } + + public function addWeek(songs:Array, weekNum:Int, ?songCharacters:Array) + { + if (songCharacters == null) + songCharacters = ['bf']; + + var num:Int = 0; + for (song in songs) + { + addSong(song, weekNum, songCharacters[num]); + + if (songCharacters.length != 1) + num++; + } + } + + var touchY:Float = 0; + var touchX:Float = 0; + var dxTouch:Float = 0; + var dyTouch:Float = 0; + var velTouch:Float = 0; + + var veloctiyLoopShit:Float = 0; + var touchTimer:Float = 0; + + var initTouchPos:FlxPoint = new FlxPoint(); + + var spamTimer:Float = 0; + var spamming:Bool = false; + + override function update(elapsed:Float) + { + super.update(elapsed); + + if (FlxG.keys.justPressed.F) + { + var realShit = curSelected; + songs[curSelected].isFav = !songs[curSelected].isFav; + if (songs[curSelected].isFav) + { + FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4, { + ease: FlxEase.elasticOut, + onComplete: _ -> + { + grpCapsules.members[realShit].favIcon.visible = true; + grpCapsules.members[realShit].favIcon.animation.play("fav"); + } + }); + } + else + { + grpCapsules.members[realShit].favIcon.animation.play('fav', false, true); + new FlxTimer().start((1 / 24) * 14, _ -> + { + grpCapsules.members[realShit].favIcon.visible = false; + }); + new FlxTimer().start((1 / 24) * 24, _ -> + { + FlxTween.tween(grpCapsules.members[realShit], {angle: 0}, 0.4, {ease: FlxEase.elasticOut}); + }); + } + } + + if (FlxG.keys.justPressed.T) + typing.hasFocus = true; + + if (FlxG.sound.music != null) + { + if (FlxG.sound.music.volume < 0.7) + { + FlxG.sound.music.volume += 0.5 * elapsed; + } + } + + lerpScore = CoolUtil.coolLerp(lerpScore, intendedScore, 0.2); + lerpCompletion = CoolUtil.coolLerp(lerpCompletion, intendedCompletion, 0.9); + + fp.updateScore(Std.int(lerpScore)); + + txtCompletion.text = Math.floor(lerpCompletion * 100) + "%"; + // trace(Highscore.getCompletion(songs[curSelected].songName, curDifficulty)); + + // trace(intendedScore); + // trace(lerpScore); + // Highscore.getAllScores(); + + var upP = controls.UI_UP_P; + var downP = controls.UI_DOWN_P; + var accepted = controls.ACCEPT; + + if (FlxG.onMobile) + { + for (touch in FlxG.touches.list) + { + if (touch.justPressed) + { + initTouchPos.set(touch.screenX, touch.screenY); + } + if (touch.pressed) + { + var dx = initTouchPos.x - touch.screenX; + var dy = initTouchPos.y - touch.screenY; + + var angle = Math.atan2(dy, dx); + var length = Math.sqrt(dx * dx + dy * dy); + + FlxG.watch.addQuick("LENGTH", length); + FlxG.watch.addQuick("ANGLE", Math.round(FlxAngle.asDegrees(angle))); + trace("ANGLE", Math.round(FlxAngle.asDegrees(angle))); + } + + /* switch (inputID) + { + case FlxObject.UP: + return + case FlxObject.DOWN: + } + */ + } + + if (FlxG.touches.getFirst() != null) + { + if (touchTimer >= 1.5) + accepted = true; + + touchTimer += elapsed; + var touch:FlxTouch = FlxG.touches.getFirst(); + + velTouch = Math.abs((touch.screenY - dyTouch)) / 50; + + dyTouch = touch.screenY - touchY; + dxTouch = touch.screenX - touchX; + + if (touch.justPressed) + { + touchY = touch.screenY; + dyTouch = 0; + velTouch = 0; + + touchX = touch.screenX; + dxTouch = 0; + } + + if (Math.abs(dxTouch) >= 100) + { + touchX = touch.screenX; + if (dxTouch != 0) + dxTouch < 0 ? changeDiff(1) : changeDiff(-1); + } + + if (Math.abs(dyTouch) >= 100) + { + touchY = touch.screenY; + + if (dyTouch != 0) + dyTouch < 0 ? changeSelection(1) : changeSelection(-1); + // changeSelection(1); + } + } + else + { + touchTimer = 0; + } + } + + #if mobile + for (touch in FlxG.touches.list) + { + if (touch.justPressed) + { + // accepted = true; + } + } + #end + + if (controls.UI_UP || controls.UI_DOWN) + { + spamTimer += elapsed; + + if (spamming) + { + if (spamTimer >= 0.07) + { + spamTimer = 0; + + if (controls.UI_UP) + changeSelection(-1); + else + changeSelection(1); + } + } + else if (spamTimer >= 0.9) + spamming = true; + } + else + { + spamming = false; + spamTimer = 0; + } + + if (upP) + { + dj.resetAFKTimer(); + changeSelection(-1); + } + if (downP) + { + dj.resetAFKTimer(); + changeSelection(1); + } + + if (FlxG.mouse.wheel != 0) + { + dj.resetAFKTimer(); + changeSelection(-Math.round(FlxG.mouse.wheel / 4)); + } + + if (controls.UI_LEFT_P) + { + dj.resetAFKTimer(); + changeDiff(-1); + } + if (controls.UI_RIGHT_P) + { + dj.resetAFKTimer(); + changeDiff(1); + } + + if (controls.BACK && !typing.hasFocus) + { + FlxG.sound.play(Paths.sound('cancelMenu')); + + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + FlxG.switchState(new MainMenuState()); + } + + if (accepted) + { + // if (Assets.exists()) + + var poop:String = songs[curSelected].songName.toLowerCase(); + + // does not work properly, always just accidentally sets it to normal anyways! + /* if (!Assets.exists(Paths.json(songs[curSelected].songName + '/' + poop))) + { + // defaults to normal if HARD / EASY doesn't exist + // does not account if NORMAL doesn't exist! + FlxG.log.warn("CURRENT DIFFICULTY IS NOT CHARTED, DEFAULTING TO NORMAL!"); + poop = Highscore.formatSong(songs[curSelected].songName.toLowerCase(), 1); + curDifficulty = 1; + }*/ + + // TODO: Deprecate and remove this entirely once all songs are converted to the new format + PlayState.currentSong = SongLoad.loadFromJson(poop, songs[curSelected].songName.toLowerCase()); + PlayState.currentSong_NEW = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase()); + PlayState.isStoryMode = false; + PlayState.storyDifficulty = curDifficulty; + PlayState.storyDifficulty_NEW = switch (curDifficulty) + { + case 0: + 'easy'; + case 1: + 'normal'; + case 2: + 'hard'; + default: 'normal'; + }; + // SongLoad.curDiff = Highscore.formatSong() + + SongLoad.curDiff = PlayState.storyDifficulty_NEW; + + PlayState.storyWeek = songs[curSelected].week; + // trace(' CUR WEEK ' + PlayState.storyWeek); + + // Visual and audio effects. + FlxG.sound.play(Paths.sound('confirmMenu')); + dj.confirm(); + + new FlxTimer().start(1, function(tmr:FlxTimer) + { + LoadingState.loadAndSwitchState(new PlayState(), true); + }); + } + } + + override function switchTo(nextState:FlxState):Bool + { + clearDaCache(songs[curSelected].songName); + return super.switchTo(nextState); + } + + function changeDiff(change:Int = 0) + { + touchTimer = 0; + + curDifficulty += change; + + if (curDifficulty < 0) + curDifficulty = 2; + if (curDifficulty > 2) + curDifficulty = 0; + + // intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty); + intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty); + intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty); + + PlayState.storyDifficulty = curDifficulty; + PlayState.storyDifficulty_NEW = switch (curDifficulty) + { + case 0: + 'easy'; + case 1: + 'normal'; + case 2: + 'hard'; + default: + 'normal'; + }; + + grpDifficulties.group.forEach(function(spr) + { + spr.visible = false; + }); + + var curShit:FlxSprite = grpDifficulties.group.members[curDifficulty]; + + curShit.visible = true; + curShit.offset.y += 5; + curShit.alpha = 0.5; + new FlxTimer().start(1 / 24, function(swag) + { + curShit.alpha = 1; + curShit.updateHitbox(); + }); + } + + // Clears the cache of songs, frees up memory, they' ll have to be loaded in later tho function clearDaCache(actualSongTho:String) + function clearDaCache(actualSongTho:String) + { + for (song in songs) + { + if (song.songName != actualSongTho) + { + trace('trying to remove: ' + song.songName); + // openfl.Assets.cache.clear(Paths.inst(song.songName)); + } + } + } + + function changeSelection(change:Int = 0) + { + // fp.updateScore(12345); + + NGio.logEvent('Fresh'); + + // NGio.logEvent('Fresh'); + FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); + + curSelected += change; + + if (curSelected < 0) + curSelected = grpCapsules.members.length - 1; + if (curSelected >= grpCapsules.members.length) + curSelected = 0; + + // selector.y = (70 * curSelected) + 30; + + // intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty); + intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty); + intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty); + // lerpScore = 0; + + #if PRELOAD_ALL + // FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName), 0); + #end + + var bullShit:Int = 0; + + for (i in 0...iconArray.length) + { + iconArray[i].alpha = 0.6; + } + + iconArray[curSelected].alpha = 1; + + for (index => capsule in grpCapsules.members) + { + capsule.selected = false; + + capsule.targetPos.y = ((index - curSelected) * 150) + 160; + capsule.targetPos.x = 270 + (60 * (Math.sin(index - curSelected))); + // capsule.targetPos.x = 320 + (40 * (index - curSelected)); + + if (index < curSelected) + capsule.targetPos.y -= 100; // another 100 for good measure + } + + if (grpCapsules.members.length > 0) + grpCapsules.members[curSelected].selected = true; + } } class DifficultySelector extends FlxSprite { - var controls:Controls; - var whiteShader:PureColor; + var controls:Controls; + var whiteShader:PureColor; - public function new(x:Float, y:Float, flipped:Bool, controls:Controls) - { - super(x, y); + public function new(x:Float, y:Float, flipped:Bool, controls:Controls) + { + super(x, y); - this.controls = controls; + this.controls = controls; - frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); - animation.addByPrefix('shine', "arrow pointer loop", 24); - animation.play('shine'); + frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); + animation.addByPrefix('shine', "arrow pointer loop", 24); + animation.play('shine'); - whiteShader = new PureColor(FlxColor.WHITE); + whiteShader = new PureColor(FlxColor.WHITE); - shader = whiteShader; + shader = whiteShader; - flipX = flipped; - } + flipX = flipped; + } - override function update(elapsed:Float) - { - if (flipX && controls.UI_RIGHT_P) - moveShitDown(); - if (!flipX && controls.UI_LEFT_P) - moveShitDown(); + override function update(elapsed:Float) + { + if (flipX && controls.UI_RIGHT_P) + moveShitDown(); + if (!flipX && controls.UI_LEFT_P) + moveShitDown(); - super.update(elapsed); - } + super.update(elapsed); + } - function moveShitDown() - { - offset.y -= 5; + function moveShitDown() + { + offset.y -= 5; - whiteShader.colorSet = true; + whiteShader.colorSet = true; - new FlxTimer().start(2 / 24, function(tmr) - { - whiteShader.colorSet = false; - updateHitbox(); - }); - } + new FlxTimer().start(2 / 24, function(tmr) + { + whiteShader.colorSet = false; + updateHitbox(); + }); + } } typedef SongFilter = { - var filterType:FilterType; - var ?filterData:Dynamic; + var filterType:FilterType; + var ?filterData:Dynamic; } enum abstract FilterType(String) { - var STARTSWITH; - var FAVORITE; - var ALL; + var STARTSWITH; + var FAVORITE; + var ALL; } class SongMetadata { - public var songName:String = ""; - public var week:Int = 0; - public var songCharacter:String = ""; - public var isFav:Bool = false; + public var songName:String = ""; + public var week:Int = 0; + public var songCharacter:String = ""; + public var isFav:Bool = false; - public function new(song:String, week:Int, songCharacter:String, ?isFav:Bool = false) - { - this.songName = song; - this.week = week; - this.songCharacter = songCharacter; - this.isFav = isFav; - } + public function new(song:String, week:Int, songCharacter:String, ?isFav:Bool = false) + { + this.songName = song; + this.week = week; + this.songCharacter = songCharacter; + this.isFav = isFav; + } } diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index 7f192c170..85dc4e4cb 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -11,7 +11,7 @@ import flixel.util.FlxColor; import funkin.modding.module.ModuleHandler; import funkin.play.PlayState; import funkin.play.character.CharacterData.CharacterDataParser; -import funkin.play.event.SongEvent.SongEventHandler; +import funkin.play.event.SongEvent.SongEventParser; import funkin.play.song.SongData.SongDataParser; import funkin.play.stage.StageData; import funkin.ui.PreferencesMenu; @@ -32,196 +32,199 @@ import Discord.DiscordClient; */ class InitState extends FlxTransitionableState { - override public function create():Void - { - trace('This is a debug build, loading InitState...'); - #if android - FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK]; - #end - #if newgrounds - NGio.init(); - #end - #if discord_rpc - DiscordClient.initialize(); + override public function create():Void + { + trace('This is a debug build, loading InitState...'); + #if android + FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK]; + #end + #if newgrounds + NGio.init(); + #end + #if discord_rpc + DiscordClient.initialize(); - Application.current.onExit.add(function(exitCode) - { - DiscordClient.shutdown(); - }); - #end + Application.current.onExit.add(function(exitCode) + { + DiscordClient.shutdown(); + }); + #end - // ==== flixel shit ==== // + // ==== flixel shit ==== // - // This big obnoxious white button is for MOBILE, so that you can press it - // easily with your finger when debug bullshit pops up during testing lol! - FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() - { - FlxG.debugger.visible = false; - }); + // This big obnoxious white button is for MOBILE, so that you can press it + // easily with your finger when debug bullshit pops up during testing lol! + FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() + { + FlxG.debugger.visible = false; + }); - FlxG.sound.muteKeys = [ZERO]; - FlxG.game.focusLostFramerate = 60; + FlxG.sound.muteKeys = [ZERO]; + FlxG.game.focusLostFramerate = 60; - // FlxG.stage.window.borderless = true; - // FlxG.stage.window.mouseLock = true; + // FlxG.stage.window.borderless = true; + // FlxG.stage.window.mouseLock = true; - var diamond:FlxGraphic = FlxGraphic.fromClass(GraphicTransTileDiamond); - diamond.persist = true; - diamond.destroyOnNoUse = false; + var diamond:FlxGraphic = FlxGraphic.fromClass(GraphicTransTileDiamond); + diamond.persist = true; + diamond.destroyOnNoUse = false; - FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32}, - new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4)); - FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32}, - new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4)); + FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32}, + new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4)); + FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32}, + new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4)); - // ===== save shit ===== // + // ===== save shit ===== // - FlxG.save.bind('funkin', 'ninjamuffin99'); + FlxG.save.bind('funkin', 'ninjamuffin99'); - // https://github.com/HaxeFlixel/flixel/pull/2396 - // IF/WHEN MY PR GOES THRU AND IT GETS INTO MAIN FLIXEL, DELETE THIS CHUNKOF CODE, AND THEN UNCOMMENT THE LINE BELOW - // FlxG.sound.loadSavedPrefs(); + // https://github.com/HaxeFlixel/flixel/pull/2396 + // IF/WHEN MY PR GOES THRU AND IT GETS INTO MAIN FLIXEL, DELETE THIS CHUNKOF CODE, AND THEN UNCOMMENT THE LINE BELOW + // FlxG.sound.loadSavedPrefs(); - if (FlxG.save.data.volume != null) - FlxG.sound.volume = FlxG.save.data.volume; - if (FlxG.save.data.mute != null) - FlxG.sound.muted = FlxG.save.data.mute; + if (FlxG.save.data.volume != null) + FlxG.sound.volume = FlxG.save.data.volume; + if (FlxG.save.data.mute != null) + FlxG.sound.muted = FlxG.save.data.mute; - // Make errors and warnings less annoying. - LogStyle.ERROR.openConsole = false; - LogStyle.ERROR.errorSound = null; - LogStyle.WARNING.openConsole = false; - LogStyle.WARNING.errorSound = null; + // Make errors and warnings less annoying. + LogStyle.ERROR.openConsole = false; + LogStyle.ERROR.errorSound = null; + LogStyle.WARNING.openConsole = false; + LogStyle.WARNING.errorSound = null; - // FlxG.save.close(); - // FlxG.sound.loadSavedPrefs(); - WindowUtil.initWindowEvents(); + // FlxG.save.close(); + // FlxG.sound.loadSavedPrefs(); + WindowUtil.initWindowEvents(); + WindowUtil.disableCrashHandler(); - PreferencesMenu.initPrefs(); - PlayerSettings.init(); - Highscore.load(); + PreferencesMenu.initPrefs(); + PlayerSettings.init(); + Highscore.load(); - if (FlxG.save.data.weekUnlocked != null) - { - // FIX LATER!!! - // WEEK UNLOCK PROGRESSION!! - // StoryMenuState.weekUnlocked = FlxG.save.data.weekUnlocked; + if (FlxG.save.data.weekUnlocked != null) + { + // FIX LATER!!! + // WEEK UNLOCK PROGRESSION!! + // StoryMenuState.weekUnlocked = FlxG.save.data.weekUnlocked; - if (StoryMenuState.weekUnlocked.length < 4) - StoryMenuState.weekUnlocked.insert(0, true); + if (StoryMenuState.weekUnlocked.length < 4) + StoryMenuState.weekUnlocked.insert(0, true); - // QUICK PATCH OOPS! - if (!StoryMenuState.weekUnlocked[0]) - StoryMenuState.weekUnlocked[0] = true; - } + // QUICK PATCH OOPS! + if (!StoryMenuState.weekUnlocked[0]) + StoryMenuState.weekUnlocked[0] = true; + } - if (FlxG.save.data.seenVideo != null) - VideoState.seenVideo = FlxG.save.data.seenVideo; + if (FlxG.save.data.seenVideo != null) + VideoState.seenVideo = FlxG.save.data.seenVideo; - // ===== fuck outta here ===== // + // ===== fuck outta here ===== // - // FlxTransitionableState.skipNextTransOut = true; - FlxTransitionableState.skipNextTransIn = true; + // FlxTransitionableState.skipNextTransOut = true; + FlxTransitionableState.skipNextTransIn = true; - SongEventHandler.registerBaseEventCallbacks(); - // TODO: Register custom event callbacks here + // TODO: Register custom event callbacks here - SongDataParser.loadSongCache(); - StageDataParser.loadStageCache(); - CharacterDataParser.loadCharacterCache(); - ModuleHandler.buildModuleCallbacks(); - ModuleHandler.loadModuleCache(); + SongEventParser.loadEventCache(); + SongDataParser.loadSongCache(); + StageDataParser.loadStageCache(); + CharacterDataParser.loadCharacterCache(); + ModuleHandler.buildModuleCallbacks(); + ModuleHandler.loadModuleCache(); - FlxG.debugger.toggleKeys = [F2]; + FlxG.debugger.toggleKeys = [F2]; - #if song - var song = getSong(); + ModuleHandler.callOnCreate(); - var weeks = [ - ['bopeebo', 'fresh', 'dadbattle'], - ['spookeez', 'south', 'monster'], - ['spooky', 'spooky', 'monster'], - ['pico', 'philly', 'blammed'], - ['satin-panties', 'high', 'milf'], - ['cocoa', 'eggnog', 'winter-horrorland'], - ['senpai', 'roses', 'thorns'], - ['ugh', 'guns', 'stress'] - ]; + #if song + var song = getSong(); - var week = 0; - for (i in 0...weeks.length) - { - if (weeks[i].contains(song)) - { - week = i + 1; - break; - } - } + var weeks = [ + ['bopeebo', 'fresh', 'dadbattle'], + ['spookeez', 'south', 'monster'], + ['spooky', 'spooky', 'monster'], + ['pico', 'philly', 'blammed'], + ['satin-panties', 'high', 'milf'], + ['cocoa', 'eggnog', 'winter-horrorland'], + ['senpai', 'roses', 'thorns'], + ['ugh', 'guns', 'stress'] + ]; - if (week == 0) - throw 'Invalid -D song=$song'; + var week = 0; + for (i in 0...weeks.length) + { + if (weeks[i].contains(song)) + { + week = i + 1; + break; + } + } - startSong(week, song, false); - #elseif week - var week = getWeek(); + if (week == 0) + throw 'Invalid -D song=$song'; - var songs = [ - 'bopeebo', 'spookeez', 'spooky', 'pico', - 'satin-panties', 'cocoa', 'senpai', 'ugh' - ]; + startSong(week, song, false); + #elseif week + var week = getWeek(); - if (week <= 0 || week >= songs.length) - throw "invalid -D week=" + week; + var songs = [ + 'bopeebo', 'spookeez', 'spooky', 'pico', + 'satin-panties', 'cocoa', 'senpai', 'ugh' + ]; - startSong(week, songs[week - 1], true); - #elseif FREEPLAY - FlxG.switchState(new FreeplayState()); - #elseif ANIMATE - FlxG.switchState(new funkin.animate.dotstuff.DotStuffTestStage()); - #elseif CHARTING - FlxG.switchState(new ChartingState()); - #elseif STAGEBUILD - FlxG.switchState(new StageBuilderState()); - #elseif FIGHT - FlxG.switchState(new PicoFight()); - #elseif ANIMDEBUG - FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState()); - #elseif LATENCY - FlxG.switchState(new LatencyState()); - #elseif NETTEST - FlxG.switchState(new netTest.NetTest()); - #else - FlxG.sound.cache(Paths.music('freakyMenu')); - FlxG.switchState(new TitleState()); - #end - } + if (week <= 0 || week >= songs.length) + throw "invalid -D week=" + week; - function startSong(week, song, isStoryMode) - { - var dif = getDif(); + startSong(week, songs[week - 1], true); + #elseif FREEPLAY + FlxG.switchState(new FreeplayState()); + #elseif ANIMATE + FlxG.switchState(new funkin.animate.dotstuff.DotStuffTestStage()); + #elseif CHARTING + FlxG.switchState(new ChartingState()); + #elseif STAGEBUILD + FlxG.switchState(new StageBuilderState()); + #elseif FIGHT + FlxG.switchState(new PicoFight()); + #elseif ANIMDEBUG + FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState()); + #elseif LATENCY + FlxG.switchState(new LatencyState()); + #elseif NETTEST + FlxG.switchState(new netTest.NetTest()); + #else + FlxG.sound.cache(Paths.music('freakyMenu')); + FlxG.switchState(new TitleState()); + #end + } - PlayState.currentSong = SongLoad.loadFromJson(song, song); - PlayState.currentSong_NEW = SongDataParser.fetchSong(song); - PlayState.isStoryMode = isStoryMode; - PlayState.storyDifficulty = dif; - PlayState.storyDifficulty_NEW = switch (dif) - { - case 0: 'easy'; - case 1: 'normal'; - case 2: 'hard'; - default: 'normal'; - }; - SongLoad.curDiff = PlayState.storyDifficulty_NEW; - PlayState.storyWeek = week; - LoadingState.loadAndSwitchState(new PlayState()); - } + function startSong(week, song, isStoryMode) + { + var dif = getDif(); + + PlayState.currentSong = SongLoad.loadFromJson(song, song); + PlayState.currentSong_NEW = SongDataParser.fetchSong(song); + PlayState.isStoryMode = isStoryMode; + PlayState.storyDifficulty = dif; + PlayState.storyDifficulty_NEW = switch (dif) + { + case 0: 'easy'; + case 1: 'normal'; + case 2: 'hard'; + default: 'normal'; + }; + SongLoad.curDiff = PlayState.storyDifficulty_NEW; + PlayState.storyWeek = week; + LoadingState.loadAndSwitchState(new PlayState()); + } } function getWeek() - return Std.parseInt(MacroUtil.getDefine("week")); + return Std.parseInt(MacroUtil.getDefine("week")); function getSong() - return MacroUtil.getDefine("song"); + return MacroUtil.getDefine("song"); function getDif() - return Std.parseInt(MacroUtil.getDefine("dif", "1")); + return Std.parseInt(MacroUtil.getDefine("dif", "1")); diff --git a/source/funkin/freeplayStuff/DJBoyfriend.hx b/source/funkin/freeplayStuff/DJBoyfriend.hx index 2e0954563..a4afb9753 100644 --- a/source/funkin/freeplayStuff/DJBoyfriend.hx +++ b/source/funkin/freeplayStuff/DJBoyfriend.hx @@ -6,178 +6,178 @@ import funkin.util.assets.FlxAnimationUtil; class DJBoyfriend extends FlxSprite { - // Represents the sprite's current status. - // Without state machines I would have driven myself crazy years ago. - public var currentState:DJBoyfriendState = Intro; + // Represents the sprite's current status. + // Without state machines I would have driven myself crazy years ago. + public var currentState:DJBoyfriendState = Intro; - // A callback activated when the intro animation finishes. - public var onIntroDone:FlxSignal = new FlxSignal(); + // A callback activated when the intro animation finishes. + public var onIntroDone:FlxSignal = new FlxSignal(); - // A callback activated when Boyfriend gets spooked. - public var onSpook:FlxSignal = new FlxSignal(); + // A callback activated when Boyfriend gets spooked. + public var onSpook:FlxSignal = new FlxSignal(); - // playAnim stolen from Character.hx, cuz im lazy lol! - // TODO: Switch this class to use SwagSprite instead. - public var animOffsets:Map>; + // playAnim stolen from Character.hx, cuz im lazy lol! + // TODO: Switch this class to use SwagSprite instead. + public var animOffsets:Map>; - static final SPOOK_PERIOD:Float = 180.0; + static final SPOOK_PERIOD:Float = 180.0; - // Time since dad last SPOOKED you. - var timeSinceSpook:Float = 0; + // Time since dad last SPOOKED you. + var timeSinceSpook:Float = 0; - public function new(x:Float, y:Float) - { - super(x, y); + public function new(x:Float, y:Float) + { + super(x, y); - animOffsets = new Map>(); + animOffsets = new Map>(); - setupAnimations(); + setupAnimations(); - animation.finishCallback = onFinishAnim; - } + animation.finishCallback = onFinishAnim; + } - public override function update(elapsed:Float):Void - { - super.update(elapsed); + public override function update(elapsed:Float):Void + { + super.update(elapsed); - if (FlxG.keys.justPressed.LEFT) - { - animOffsets["confirm"] = [animOffsets["confirm"][0] + 1, animOffsets["confirm"][1]]; - applyAnimOffset(); - } - else if (FlxG.keys.justPressed.RIGHT) - { - animOffsets["confirm"] = [animOffsets["confirm"][0] - 1, animOffsets["confirm"][1]]; - applyAnimOffset(); - } - else if (FlxG.keys.justPressed.UP) - { - animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] + 1]; - applyAnimOffset(); - } - else if (FlxG.keys.justPressed.DOWN) - { - animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] - 1]; - applyAnimOffset(); - } + if (FlxG.keys.justPressed.LEFT) + { + animOffsets["confirm"] = [animOffsets["confirm"][0] + 1, animOffsets["confirm"][1]]; + applyAnimOffset(); + } + else if (FlxG.keys.justPressed.RIGHT) + { + animOffsets["confirm"] = [animOffsets["confirm"][0] - 1, animOffsets["confirm"][1]]; + applyAnimOffset(); + } + else if (FlxG.keys.justPressed.UP) + { + animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] + 1]; + applyAnimOffset(); + } + else if (FlxG.keys.justPressed.DOWN) + { + animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] - 1]; + applyAnimOffset(); + } - switch (currentState) - { - case Intro: - // Play the intro animation then leave this state immediately. - if (getCurrentAnimation() != 'intro') - playAnimation('intro', true); - timeSinceSpook = 0; - case Idle: - // We are in this state the majority of the time. - if (getCurrentAnimation() != 'idle' || animation.finished) - { - if (timeSinceSpook > SPOOK_PERIOD) - { - currentState = Spook; - } - else - { - playAnimation('idle', false); - } - } - timeSinceSpook += elapsed; - case Confirm: - if (getCurrentAnimation() != 'confirm') - playAnimation('confirm', false); - timeSinceSpook = 0; - case Spook: - if (getCurrentAnimation() != 'spook') - { - onSpook.dispatch(); - playAnimation('spook', false); - } - timeSinceSpook = 0; - default: - // I shit myself. - } - } + switch (currentState) + { + case Intro: + // Play the intro animation then leave this state immediately. + if (getCurrentAnimation() != 'intro') + playAnimation('intro', true); + timeSinceSpook = 0; + case Idle: + // We are in this state the majority of the time. + if (getCurrentAnimation() != 'idle' || animation.finished) + { + if (timeSinceSpook > SPOOK_PERIOD) + { + currentState = Spook; + } + else + { + playAnimation('idle', false); + } + } + timeSinceSpook += elapsed; + case Confirm: + if (getCurrentAnimation() != 'confirm') + playAnimation('confirm', false); + timeSinceSpook = 0; + case Spook: + if (getCurrentAnimation() != 'spook') + { + onSpook.dispatch(); + playAnimation('spook', false); + } + timeSinceSpook = 0; + default: + // I shit myself. + } + } - function onFinishAnim(name:String):Void - { - switch (name) - { - case "intro": - trace('Finished intro'); - currentState = Idle; - onIntroDone.dispatch(); - case "idle": - trace('Finished idle'); - case "spook": - trace('Finished spook'); - currentState = Idle; - case "confirm": - trace('Finished confirm'); - } - } + function onFinishAnim(name:String):Void + { + switch (name) + { + case "intro": + // trace('Finished intro'); + currentState = Idle; + onIntroDone.dispatch(); + case "idle": + // trace('Finished idle'); + case "spook": + // trace('Finished spook'); + currentState = Idle; + case "confirm": + // trace('Finished confirm'); + } + } - public function resetAFKTimer():Void - { - timeSinceSpook = 0; - } + public function resetAFKTimer():Void + { + timeSinceSpook = 0; + } - function setupAnimations():Void - { - frames = FlxAnimationUtil.combineFramesCollections(Paths.getSparrowAtlas('freeplay/bfFreeplay'), Paths.getSparrowAtlas('freeplay/bf-freeplay-afk')); + function setupAnimations():Void + { + frames = FlxAnimationUtil.combineFramesCollections(Paths.getSparrowAtlas('freeplay/bfFreeplay'), Paths.getSparrowAtlas('freeplay/bf-freeplay-afk')); - animation.addByPrefix('intro', "boyfriend dj intro", 24, false); - addOffset('intro', 0, 0); + animation.addByPrefix('intro', "boyfriend dj intro", 24, false); + addOffset('intro', 0, 0); - animation.addByPrefix('idle', "Boyfriend DJ0", 24, false); - addOffset('idle', -4, -426); + animation.addByPrefix('idle', "Boyfriend DJ0", 24, false); + addOffset('idle', -4, -426); - animation.addByPrefix('confirm', "Boyfriend DJ confirm", 24, false); - addOffset('confirm', 40, -451); + animation.addByPrefix('confirm', "Boyfriend DJ confirm", 24, false); + addOffset('confirm', 40, -451); - animation.addByPrefix('spook', "bf dj afk0", 24, false); - addOffset('spook', -3, -272); - } + animation.addByPrefix('spook', "bf dj afk0", 24, false); + addOffset('spook', -3, -272); + } - public function confirm():Void - { - currentState = Confirm; - } + public function confirm():Void + { + currentState = Confirm; + } - public inline function addOffset(name:String, x:Float = 0, y:Float = 0) - { - animOffsets[name] = [x, y]; - } + public inline function addOffset(name:String, x:Float = 0, y:Float = 0) + { + animOffsets[name] = [x, y]; + } - public function getCurrentAnimation():String - { - if (this.animation == null || this.animation.curAnim == null) - return ""; - return this.animation.curAnim.name; - } + public function getCurrentAnimation():String + { + if (this.animation == null || this.animation.curAnim == null) + return ""; + return this.animation.curAnim.name; + } - public function playAnimation(AnimName:String, Force:Bool = false, Reversed:Bool = false, Frame:Int = 0):Void - { - animation.play(AnimName, Force, Reversed, Frame); - applyAnimOffset(); - } + public function playAnimation(AnimName:String, Force:Bool = false, Reversed:Bool = false, Frame:Int = 0):Void + { + animation.play(AnimName, Force, Reversed, Frame); + applyAnimOffset(); + } - function applyAnimOffset() - { - var AnimName = getCurrentAnimation(); - var daOffset = animOffsets.get(AnimName); - if (animOffsets.exists(AnimName)) - { - offset.set(daOffset[0], daOffset[1]); - } - else - offset.set(0, 0); - } + function applyAnimOffset() + { + var AnimName = getCurrentAnimation(); + var daOffset = animOffsets.get(AnimName); + if (animOffsets.exists(AnimName)) + { + offset.set(daOffset[0], daOffset[1]); + } + else + offset.set(0, 0); + } } enum DJBoyfriendState { - Intro; - Idle; - Confirm; - Spook; + Intro; + Idle; + Confirm; + Spook; } diff --git a/source/funkin/import.hx b/source/funkin/import.hx index 35cd7b869..56b6aa4d3 100644 --- a/source/funkin/import.hx +++ b/source/funkin/import.hx @@ -6,8 +6,7 @@ import flixel.FlxG; // This one in particular causes a compile error if you're u // These are great. using Lambda; using StringTools; - +using funkin.util.tools.MapTools; using funkin.util.tools.IteratorTools; using funkin.util.tools.StringTools; - #end diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx index b6eca7e68..fe2e983b3 100644 --- a/source/funkin/modding/IScriptedClass.hx +++ b/source/funkin/modding/IScriptedClass.hx @@ -60,6 +60,7 @@ interface IPlayStateScriptedClass extends IScriptedClass * and can be cancelled by scripts. */ public function onPause(event:PauseScriptEvent):Void; + /** * Called when the game is unpaused. */ @@ -70,18 +71,22 @@ interface IPlayStateScriptedClass extends IScriptedClass * Use this to mutate the chart. */ public function onSongLoaded(event:SongLoadScriptEvent):Void; + /** * Called when the song starts (conductor time is 0 seconds). */ public function onSongStart(event:ScriptEvent):Void; + /** * Called when the song ends and the song is about to be unloaded. */ public function onSongEnd(event:ScriptEvent):Void; + /** * Called as the player runs out of health just before the game over substate is entered. */ public function onGameOver(event:ScriptEvent):Void; + /** * Called when the player restarts the song, either via pause menu or restarting after a game over. */ @@ -92,19 +97,27 @@ interface IPlayStateScriptedClass extends IScriptedClass * Query the note attached to the event to determine if it was hit by the player or CPU. */ public function onNoteHit(event:NoteScriptEvent):Void; + /** * Called when EITHER player (usually the player) misses a note. */ public function onNoteMiss(event:NoteScriptEvent):Void; + /** * Called when the player presses a key when no note is on the strumline. */ public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void; + /** + * Called when the song reaches an event. + */ + public function onSongEvent(event:SongEventScriptEvent):Void; + /** * Called once every step of the song. */ public function onStepHit(event:SongTimeScriptEvent):Void; + /** * Called once every beat of the song. */ @@ -114,10 +127,12 @@ interface IPlayStateScriptedClass extends IScriptedClass * Called when the countdown of the song starts. */ public function onCountdownStart(event:CountdownScriptEvent):Void; + /** * Called when the a part of the countdown happens. */ public function onCountdownStep(event:CountdownScriptEvent):Void; + /** * Called when the countdown of the song ends. */ diff --git a/source/funkin/modding/PolymodErrorHandler.hx b/source/funkin/modding/PolymodErrorHandler.hx index c0616dfb5..70a716821 100644 --- a/source/funkin/modding/PolymodErrorHandler.hx +++ b/source/funkin/modding/PolymodErrorHandler.hx @@ -4,68 +4,88 @@ import polymod.Polymod; class PolymodErrorHandler { - /** - * Show a popup with the given text. - * This displays a system popup, it WILL interrupt the game. - * Make sure to only use this when it's important, like when there's a script error. - * - * @param name The name at the top of the popup. - * @param desc The body text of the popup. - */ - public static function showAlert(name:String, desc:String):Void - { - lime.app.Application.current.window.alert(desc, name); - } + /** + * Show a popup with the given text. + * This displays a system popup, it WILL interrupt the game. + * Make sure to only use this when it's important, like when there's a script error. + * + * @param name The name at the top of the popup. + * @param desc The body text of the popup. + */ + public static function showAlert(name:String, desc:String):Void + { + lime.app.Application.current.window.alert(desc, name); + } - public static function onPolymodError(error:PolymodError):Void - { - // Perform an action based on the error code. - switch (error.code) - { - case MOD_LOAD_PREPARE: - logInfo('[POLYMOD]: ${error.message}'); - case MOD_LOAD_DONE: - logInfo('[POLYMOD]: ${error.message}'); - case MISSING_ICON: - logWarn('[POLYMOD]: A mod is missing an icon. Please add one.'); - case SCRIPT_PARSE_ERROR: - // A syntax error when parsing a script. - logError('[POLYMOD]: ${error.message}'); - showAlert('Polymod Script Parsing Error', error.message); - case SCRIPT_EXCEPTION: - // A runtime error when running a script. - logError('[POLYMOD]: ${error.message}'); - showAlert('Polymod Script Execution Error', error.message); - case SCRIPT_CLASS_NOT_FOUND: - // A scripted class tried to reference an unknown superclass. - logError('[POLYMOD]: ${error.message}'); - showAlert('Polymod Script Parsing Error', error.message); - default: - // Log the message based on its severity. - switch (error.severity) - { - case NOTICE: - logInfo('[POLYMOD]: ${error.message}'); - case WARNING: - logWarn('[POLYMOD]: ${error.message}'); - case ERROR: - logError('[POLYMOD]: ${error.message}'); - } - } - } + public static function onPolymodError(error:PolymodError):Void + { + // Perform an action based on the error code. + switch (error.code) + { + case FRAMEWORK_INIT, FRAMEWORK_AUTODETECT, SCRIPT_PARSING: + // Unimportant. + return; - static function logInfo(message:String):Void - { - trace('[INFO ] ${message}'); - } + case MOD_LOAD_PREPARE, MOD_LOAD_DONE: + logInfo('LOADING MOD - ${error.message}'); - static function logError(message:String):Void - { - trace('[ERROR] ${message}'); - } + case MISSING_ICON: + logWarn('A mod is missing an icon. Please add one.'); - static function logWarn(message:String):Void - { - trace('[WARN ] ${message}'); - } + case SCRIPT_PARSE_ERROR: + // A syntax error when parsing a script. + logError(error.message); + // Notify the user via popup. + showAlert('Polymod Script Parsing Error', error.message); + case SCRIPT_RUNTIME_EXCEPTION: + // A runtime error when running a script. + logError(error.message); + // Notify the user via popup. + showAlert('Polymod Script Exception', error.message); + case SCRIPT_CLASS_MODULE_NOT_FOUND: + // A scripted class tried to reference an unknown class or module. + logError(error.message); + + // Last word is the class name. + var className:String = error.message.split(' ').pop(); + var msg:String = 'Import error in ${error.origin}'; + msg += '\nCould not import unknown class ${className}'; + msg += '\nCheck to ensure the class exists and is spelled correctly.'; + + // Notify the user via popup. + showAlert('Polymod Script Import Error', msg); + case SCRIPT_CLASS_MODULE_BLACKLISTED: + // A scripted class tried to reference a blacklisted class or module. + logError(error.message); + // Notify the user via popup. + showAlert('Polymod Script Blacklist Violation', error.message); + + default: + // Log the message based on its severity. + switch (error.severity) + { + case NOTICE: + logInfo(error.message); + case WARNING: + logWarn(error.message); + case ERROR: + logError(error.message); + } + } + } + + static function logInfo(message:String):Void + { + trace('[INFO-] ${message}'); + } + + static function logError(message:String):Void + { + trace('[ERROR] ${message}'); + } + + static function logWarn(message:String):Void + { + trace('[WARN-] ${message}'); + } } diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index 9ec5a968f..9586d6f46 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -1,5 +1,6 @@ package funkin.modding; +import funkin.util.macro.ClassMacro; import funkin.modding.module.ModuleHandler; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.song.SongData; @@ -11,251 +12,271 @@ import funkin.util.FileUtil; class PolymodHandler { - /** - * The API version that mods should comply with. - * Format this with Semantic Versioning; ... - * Bug fixes increment the patch version, new features increment the minor version. - * Changes that break old mods increment the major version. - */ - static final API_VERSION = "0.1.0"; + /** + * The API version that mods should comply with. + * Format this with Semantic Versioning; ... + * Bug fixes increment the patch version, new features increment the minor version. + * Changes that break old mods increment the major version. + */ + static final API_VERSION = "0.1.0"; - /** - * Where relative to the executable that mods are located. - */ - static final MOD_FOLDER = "mods"; + /** + * Where relative to the executable that mods are located. + */ + static final MOD_FOLDER = "mods"; - public static function createModRoot() - { - FileUtil.createDirIfNotExists(MOD_FOLDER); - } + public static function createModRoot() + { + FileUtil.createDirIfNotExists(MOD_FOLDER); + } - /** - * Loads the game with ALL mods enabled with Polymod. - */ - public static function loadAllMods() - { - // Create the mod root if it doesn't exist. - createModRoot(); - trace("Initializing Polymod (using all mods)..."); - loadModsById(getAllModIds()); - } + /** + * Loads the game with ALL mods enabled with Polymod. + */ + public static function loadAllMods() + { + // Create the mod root if it doesn't exist. + createModRoot(); + trace("Initializing Polymod (using all mods)..."); + loadModsById(getAllModIds()); + } - /** - * Loads the game with configured mods enabled with Polymod. - */ - public static function loadEnabledMods() - { - // Create the mod root if it doesn't exist. - createModRoot(); + /** + * Loads the game with configured mods enabled with Polymod. + */ + public static function loadEnabledMods() + { + // Create the mod root if it doesn't exist. + createModRoot(); - trace("Initializing Polymod (using configured mods)..."); - loadModsById(getEnabledModIds()); - } + trace("Initializing Polymod (using configured mods)..."); + loadModsById(getEnabledModIds()); + } - /** - * Loads the game without any mods enabled with Polymod. - */ - public static function loadNoMods() - { - // Create the mod root if it doesn't exist. - createModRoot(); + /** + * Loads the game without any mods enabled with Polymod. + */ + public static function loadNoMods() + { + // Create the mod root if it doesn't exist. + createModRoot(); - // We still need to configure the debug print calls etc. - trace("Initializing Polymod (using no mods)..."); - loadModsById([]); - } + // We still need to configure the debug print calls etc. + trace("Initializing Polymod (using no mods)..."); + loadModsById([]); + } - public static function loadModsById(ids:Array) - { - if (ids.length == 0) - { - trace('You attempted to load zero mods.'); - } - else - { - trace('Attempting to load ${ids.length} mods...'); - } - var loadedModList = polymod.Polymod.init({ - // Root directory for all mods. - modRoot: MOD_FOLDER, - // The directories for one or more mods to load. - dirs: ids, - // Framework being used to load assets. - framework: OPENFL, - // The current version of our API. - apiVersionRule: API_VERSION, - // Call this function any time an error occurs. - errorCallback: PolymodErrorHandler.onPolymodError, - // Enforce semantic version patterns for each mod. - // modVersions: null, - // A map telling Polymod what the asset type is for unfamiliar file extensions. - // extensionMap: [], + public static function loadModsById(ids:Array) + { + if (ids.length == 0) + { + trace('You attempted to load zero mods.'); + } + else + { + trace('Attempting to load ${ids.length} mods...'); + } - frameworkParams: buildFrameworkParams(), + buildImports(); - // List of filenames to ignore in mods. Use the default list to ignore the metadata file, etc. - ignoredFiles: Polymod.getDefaultIgnoreList(), + var loadedModList = polymod.Polymod.init({ + // Root directory for all mods. + modRoot: MOD_FOLDER, + // The directories for one or more mods to load. + dirs: ids, + // Framework being used to load assets. + framework: OPENFL, + // The current version of our API. + apiVersionRule: API_VERSION, + // Call this function any time an error occurs. + errorCallback: PolymodErrorHandler.onPolymodError, + // Enforce semantic version patterns for each mod. + // modVersions: null, + // A map telling Polymod what the asset type is for unfamiliar file extensions. + // extensionMap: [], - // Parsing rules for various data formats. - parseRules: buildParseRules(), + frameworkParams: buildFrameworkParams(), - // Parse hxc files and register the scripted classes in them. - useScriptedClasses: true, - }); + // List of filenames to ignore in mods. Use the default list to ignore the metadata file, etc. + ignoredFiles: Polymod.getDefaultIgnoreList(), - if (loadedModList == null) - { - trace('[POLYMOD] An error occurred! Failed when loading mods!'); - } - else - { - if (loadedModList.length == 0) - { - trace('[POLYMOD] Mod loading complete. We loaded no mods / ${ids.length} mods.'); - } - else - { - trace('[POLYMOD] Mod loading complete. We loaded ${loadedModList.length} / ${ids.length} mods.'); - } - } + // Parsing rules for various data formats. + parseRules: buildParseRules(), - for (mod in loadedModList) - { - trace(' * ${mod.title} v${mod.modVersion} [${mod.id}]'); - } + // Parse hxc files and register the scripted classes in them. + useScriptedClasses: true, + }); - #if debug - var fileList = Polymod.listModFiles(PolymodAssetType.IMAGE); - trace('[POLYMOD] Installed mods have replaced ${fileList.length} images.'); - for (item in fileList) - trace(' * $item'); + if (loadedModList == null) + { + trace('An error occurred! Failed when loading mods!'); + } + else + { + if (loadedModList.length == 0) + { + trace('Mod loading complete. We loaded no mods / ${ids.length} mods.'); + } + else + { + trace('Mod loading complete. We loaded ${loadedModList.length} / ${ids.length} mods.'); + } + } - fileList = Polymod.listModFiles(PolymodAssetType.TEXT); - trace('[POLYMOD] Installed mods have replaced ${fileList.length} text files.'); - for (item in fileList) - trace(' * $item'); + for (mod in loadedModList) + { + trace(' * ${mod.title} v${mod.modVersion} [${mod.id}]'); + } - fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_MUSIC); - trace('[POLYMOD] Installed mods have replaced ${fileList.length} music files.'); - for (item in fileList) - trace(' * $item'); + #if debug + var fileList = Polymod.listModFiles(PolymodAssetType.IMAGE); + trace('Installed mods have replaced ${fileList.length} images.'); + for (item in fileList) + trace(' * $item'); - fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_SOUND); - trace('[POLYMOD] Installed mods have replaced ${fileList.length} sound files.'); - for (item in fileList) - trace(' * $item'); + fileList = Polymod.listModFiles(PolymodAssetType.TEXT); + trace('Installed mods have added/replaced ${fileList.length} text files.'); + for (item in fileList) + trace(' * $item'); - fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_GENERIC); - trace('[POLYMOD] Installed mods have replaced ${fileList.length} generic audio files.'); - for (item in fileList) - trace(' * $item'); - #end - } + fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_MUSIC); + trace('Installed mods have replaced ${fileList.length} music files.'); + for (item in fileList) + trace(' * $item'); - static function buildParseRules():polymod.format.ParseRules - { - var output = polymod.format.ParseRules.getDefault(); - // Ensure TXT files have merge support. - output.addType("txt", TextFileFormat.LINES); - // Ensure script files have merge support. - output.addType("hscript", TextFileFormat.PLAINTEXT); - output.addType("hxs", TextFileFormat.PLAINTEXT); - output.addType("hxc", TextFileFormat.PLAINTEXT); - output.addType("hx", TextFileFormat.PLAINTEXT); + fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_SOUND); + trace('Installed mods have replaced ${fileList.length} sound files.'); + for (item in fileList) + trace(' * $item'); - // You can specify the format of a specific file, with file extension. - // output.addFile("data/introText.txt", TextFileFormat.LINES) - return output; - } + fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_GENERIC); + trace('Installed mods have replaced ${fileList.length} generic audio files.'); + for (item in fileList) + trace(' * $item'); + #end + } - static inline function buildFrameworkParams():polymod.Polymod.FrameworkParams - { - return { - assetLibraryPaths: [ - "songs" => "songs", "shared" => "", "tutorial" => "tutorial", "scripts" => "scripts", "week1" => "week1", "week2" => "week2", - "week3" => "week3", "week4" => "week4", "week5" => "week5", "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1", - ] - } - } + static function buildImports():Void + { + // Add default imports for common classes. - public static function getAllMods():Array - { - trace('Scanning the mods folder...'); - var modMetadata = Polymod.scan({ - modRoot: MOD_FOLDER, - apiVersionRule: API_VERSION, - errorCallback: PolymodErrorHandler.onPolymodError - }); - trace('Found ${modMetadata.length} mods when scanning.'); - return modMetadata; - } + // Add import aliases for certain classes. + // NOTE: Scripted classes are automatically aliased to their parent class. + Polymod.addImportAlias('flixel.math.FlxPoint', flixel.math.FlxPoint.FlxBasePoint); - public static function getAllModIds():Array - { - var modIds = [for (i in getAllMods()) i.id]; - return modIds; - } + // Add blacklisting for prohibited classes and packages. + // `polymod.*` + for (cls in ClassMacro.listClassesInPackage('polymod')) + { + var className = Type.getClassName(cls); + Polymod.blacklistImport(className); + } + } - public static function setEnabledMods(newModList:Array):Void - { - FlxG.save.data.enabledMods = newModList; - // Make sure to COMMIT the changes. - FlxG.save.flush(); - } + static function buildParseRules():polymod.format.ParseRules + { + var output = polymod.format.ParseRules.getDefault(); + // Ensure TXT files have merge support. + output.addType("txt", TextFileFormat.LINES); + // Ensure script files have merge support. + output.addType("hscript", TextFileFormat.PLAINTEXT); + output.addType("hxs", TextFileFormat.PLAINTEXT); + output.addType("hxc", TextFileFormat.PLAINTEXT); + output.addType("hx", TextFileFormat.PLAINTEXT); - /** - * Returns the list of enabled mods. - * @return Array - */ - public static function getEnabledModIds():Array - { - if (FlxG.save.data.enabledMods == null) - { - // NOTE: If the value is null, the enabled mod list is unconfigured. - // Currently, we default to disabling newly installed mods. - // If we want to auto-enable new mods, but otherwise leave the configured list in place, - // we will need some custom logic. - FlxG.save.data.enabledMods = []; - } - return FlxG.save.data.enabledMods; - } + // You can specify the format of a specific file, with file extension. + // output.addFile("data/introText.txt", TextFileFormat.LINES) + return output; + } - public static function getEnabledMods():Array - { - var modIds = getEnabledModIds(); - var modMetadata = getAllMods(); - var enabledMods = []; - for (item in modMetadata) - { - if (modIds.indexOf(item.id) != -1) - { - enabledMods.push(item); - } - } - return enabledMods; - } + static inline function buildFrameworkParams():polymod.Polymod.FrameworkParams + { + return { + assetLibraryPaths: [ + "songs" => "songs", "shared" => "", "tutorial" => "tutorial", "scripts" => "scripts", "week1" => "week1", "week2" => "week2", + "week3" => "week3", "week4" => "week4", "week5" => "week5", "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1", + ] + } + } - public static function forceReloadAssets() - { - // Forcibly clear scripts so that scripts can be edited. - ModuleHandler.clearModuleCache(); - Polymod.clearScripts(); + public static function getAllMods():Array + { + trace('Scanning the mods folder...'); + var modMetadata = Polymod.scan({ + modRoot: MOD_FOLDER, + apiVersionRule: API_VERSION, + errorCallback: PolymodErrorHandler.onPolymodError + }); + trace('Found ${modMetadata.length} mods when scanning.'); + return modMetadata; + } - // Forcibly reload Polymod so it finds any new files. - // TODO: Replace this with loadEnabledMods(). - funkin.modding.PolymodHandler.loadAllMods(); + public static function getAllModIds():Array + { + var modIds = [for (i in getAllMods()) i.id]; + return modIds; + } - // Reload scripted classes so stages and modules will update. - Polymod.registerAllScriptClasses(); + public static function setEnabledMods(newModList:Array):Void + { + FlxG.save.data.enabledMods = newModList; + // Make sure to COMMIT the changes. + FlxG.save.flush(); + } - // Reload everything that is cached. - // Currently this freezes the game for a second but I guess that's tolerable? + /** + * Returns the list of enabled mods. + * @return Array + */ + public static function getEnabledModIds():Array + { + if (FlxG.save.data.enabledMods == null) + { + // NOTE: If the value is null, the enabled mod list is unconfigured. + // Currently, we default to disabling newly installed mods. + // If we want to auto-enable new mods, but otherwise leave the configured list in place, + // we will need some custom logic. + FlxG.save.data.enabledMods = []; + } + return FlxG.save.data.enabledMods; + } - // TODO: Reload event callbacks + public static function getEnabledMods():Array + { + var modIds = getEnabledModIds(); + var modMetadata = getAllMods(); + var enabledMods = []; + for (item in modMetadata) + { + if (modIds.indexOf(item.id) != -1) + { + enabledMods.push(item); + } + } + return enabledMods; + } - SongDataParser.loadSongCache(); - StageDataParser.loadStageCache(); - CharacterDataParser.loadCharacterCache(); - ModuleHandler.loadModuleCache(); - } + public static function forceReloadAssets() + { + // Forcibly clear scripts so that scripts can be edited. + ModuleHandler.clearModuleCache(); + Polymod.clearScripts(); + + // Forcibly reload Polymod so it finds any new files. + // TODO: Replace this with loadEnabledMods(). + funkin.modding.PolymodHandler.loadAllMods(); + + // Reload scripted classes so stages and modules will update. + Polymod.registerAllScriptClasses(); + + // Reload everything that is cached. + // Currently this freezes the game for a second but I guess that's tolerable? + + // TODO: Reload event callbacks + + SongDataParser.loadSongCache(); + StageDataParser.loadStageCache(); + CharacterDataParser.loadCharacterCache(); + ModuleHandler.loadModuleCache(); + } } diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx index 3e4249063..9e31a3032 100644 --- a/source/funkin/modding/events/ScriptEvent.hx +++ b/source/funkin/modding/events/ScriptEvent.hx @@ -15,269 +15,277 @@ typedef ScriptEventType = EventType; */ class ScriptEvent { - /** - * Called when the relevant object is created. - * Keep in mind that the constructor may be called before the object is needed, - * for the purposes of caching data or otherwise. - * - * This event is not cancelable. - */ - public static inline final CREATE:ScriptEventType = "CREATE"; + /** + * Called when the relevant object is created. + * Keep in mind that the constructor may be called before the object is needed, + * for the purposes of caching data or otherwise. + * + * This event is not cancelable. + */ + public static inline final CREATE:ScriptEventType = "CREATE"; - /** - * Called when the relevant object is destroyed. - * This should perform relevant cleanup to ensure good performance. - * - * This event is not cancelable. - */ - public static inline final DESTROY:ScriptEventType = "DESTROY"; + /** + * Called when the relevant object is destroyed. + * This should perform relevant cleanup to ensure good performance. + * + * This event is not cancelable. + */ + public static inline final DESTROY:ScriptEventType = "DESTROY"; - /** - * Called during the update function. - * This is called every frame, so be careful! - * - * This event is not cancelable. - */ - public static inline final UPDATE:ScriptEventType = "UPDATE"; + /** + * Called during the update function. + * This is called every frame, so be careful! + * + * This event is not cancelable. + */ + public static inline final UPDATE:ScriptEventType = "UPDATE"; - /** - * Called when the player moves to pause the game. - * - * This event IS cancelable! Canceling the event will prevent the game from pausing. - */ - public static inline final PAUSE:ScriptEventType = "PAUSE"; + /** + * Called when the player moves to pause the game. + * + * This event IS cancelable! Canceling the event will prevent the game from pausing. + */ + public static inline final PAUSE:ScriptEventType = "PAUSE"; - /** - * Called when the player moves to unpause the game while paused. - * - * This event IS cancelable! Canceling the event will prevent the game from resuming. - */ - public static inline final RESUME:ScriptEventType = "RESUME"; + /** + * Called when the player moves to unpause the game while paused. + * + * This event IS cancelable! Canceling the event will prevent the game from resuming. + */ + public static inline final RESUME:ScriptEventType = "RESUME"; - /** - * Called once per step in the song. This happens 4 times per measure. - * - * This event is not cancelable. - */ - public static inline final SONG_BEAT_HIT:ScriptEventType = "BEAT_HIT"; + /** + * Called once per step in the song. This happens 4 times per measure. + * + * This event is not cancelable. + */ + public static inline final SONG_BEAT_HIT:ScriptEventType = "BEAT_HIT"; - /** - * Called once per step in the song. This happens 16 times per measure. - * - * This event is not cancelable. - */ - public static inline final SONG_STEP_HIT:ScriptEventType = "STEP_HIT"; + /** + * Called once per step in the song. This happens 16 times per measure. + * + * This event is not cancelable. + */ + public static inline final SONG_STEP_HIT:ScriptEventType = "STEP_HIT"; - /** - * Called when a character hits a note. - * Important information such as judgement/timing, note data, player/opponent, etc. are all provided. - * - * This event IS cancelable! Canceling this event prevents the note from being hit, - * and will likely result in a miss later. - */ - public static inline final NOTE_HIT:ScriptEventType = "NOTE_HIT"; + /** + * Called when a character hits a note. + * Important information such as judgement/timing, note data, player/opponent, etc. are all provided. + * + * This event IS cancelable! Canceling this event prevents the note from being hit, + * and will likely result in a miss later. + */ + public static inline final NOTE_HIT:ScriptEventType = "NOTE_HIT"; - /** - * Called when a character misses a note. - * Important information such as note data, player/opponent, etc. are all provided. - * - * This event IS cancelable! Canceling this event prevents the note from being considered missed, - * avoiding a combo break and lost health. - */ - public static inline final NOTE_MISS:ScriptEventType = "NOTE_MISS"; + /** + * Called when a character misses a note. + * Important information such as note data, player/opponent, etc. are all provided. + * + * This event IS cancelable! Canceling this event prevents the note from being considered missed, + * avoiding a combo break and lost health. + */ + public static inline final NOTE_MISS:ScriptEventType = "NOTE_MISS"; - /** - * Called when a character presses a note when there was none there, causing them to lose health. - * Important information such as direction pressed, etc. are all provided. - * - * This event IS cancelable! Canceling this event prevents the note from being considered missed, - * avoiding lost health/score and preventing the miss animation. - */ - public static inline final NOTE_GHOST_MISS:ScriptEventType = "NOTE_GHOST_MISS"; + /** + * Called when a character presses a note when there was none there, causing them to lose health. + * Important information such as direction pressed, etc. are all provided. + * + * This event IS cancelable! Canceling this event prevents the note from being considered missed, + * avoiding lost health/score and preventing the miss animation. + */ + public static inline final NOTE_GHOST_MISS:ScriptEventType = "NOTE_GHOST_MISS"; - /** - * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin. - * - * This event is not cancelable. - */ - public static inline final SONG_START:ScriptEventType = "SONG_START"; + /** + * Called when a song event is reached in the chart. + * + * This event IS cancelable! Cancelling this event prevents the event from being triggered, + * thus blocking its normal functionality. + */ + public static inline final SONG_EVENT:ScriptEventType = "SONG_EVENT"; - /** - * Called when the song ends. This happens as the instrumental and vocals end. - * - * This event is not cancelable. - */ - public static inline final SONG_END:ScriptEventType = "SONG_END"; + /** + * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin. + * + * This event is not cancelable. + */ + public static inline final SONG_START:ScriptEventType = "SONG_START"; - /** - * Called when the countdown begins. This occurs before the song starts. - * - * This event IS cancelable! Canceling this event will prevent the countdown from starting. - * - The song will not start until you call Countdown.performCountdown() later. - * - Note that calling performCountdown() will trigger this event again, so be sure to add logic to ignore it. - */ - public static inline final COUNTDOWN_START:ScriptEventType = "COUNTDOWN_START"; + /** + * Called when the song ends. This happens as the instrumental and vocals end. + * + * This event is not cancelable. + */ + public static inline final SONG_END:ScriptEventType = "SONG_END"; - /** - * Called when a step of the countdown happens. - * Includes information about what step of the countdown was hit. - * - * This event IS cancelable! Canceling this event will pause the countdown. - * - The countdown will not resume until you call PlayState.resumeCountdown(). - */ - public static inline final COUNTDOWN_STEP:ScriptEventType = "COUNTDOWN_STEP"; + /** + * Called when the countdown begins. This occurs before the song starts. + * + * This event IS cancelable! Canceling this event will prevent the countdown from starting. + * - The song will not start until you call Countdown.performCountdown() later. + * - Note that calling performCountdown() will trigger this event again, so be sure to add logic to ignore it. + */ + public static inline final COUNTDOWN_START:ScriptEventType = "COUNTDOWN_START"; - /** - * Called when the countdown is done but just before the song starts. - * - * This event is not cancelable. - */ - public static inline final COUNTDOWN_END:ScriptEventType = "COUNTDOWN_END"; + /** + * Called when a step of the countdown happens. + * Includes information about what step of the countdown was hit. + * + * This event IS cancelable! Canceling this event will pause the countdown. + * - The countdown will not resume until you call PlayState.resumeCountdown(). + */ + public static inline final COUNTDOWN_STEP:ScriptEventType = "COUNTDOWN_STEP"; - /** - * Called before the game over screen triggers and the death animation plays. - * - * This event is not cancelable. - */ - public static inline final GAME_OVER:ScriptEventType = "GAME_OVER"; + /** + * Called when the countdown is done but just before the song starts. + * + * This event is not cancelable. + */ + public static inline final COUNTDOWN_END:ScriptEventType = "COUNTDOWN_END"; - /** - * Called after the player presses a key to restart the game. - * This can happen from the pause menu or the game over screen. - * - * This event IS cancelable! Canceling this event will prevent the game from restarting. - */ - public static inline final SONG_RETRY:ScriptEventType = "SONG_RETRY"; + /** + * Called before the game over screen triggers and the death animation plays. + * + * This event is not cancelable. + */ + public static inline final GAME_OVER:ScriptEventType = "GAME_OVER"; - /** - * Called when the player pushes down any key on the keyboard. - * - * This event is not cancelable. - */ - public static inline final KEY_DOWN:ScriptEventType = "KEY_DOWN"; + /** + * Called after the player presses a key to restart the game. + * This can happen from the pause menu or the game over screen. + * + * This event IS cancelable! Canceling this event will prevent the game from restarting. + */ + public static inline final SONG_RETRY:ScriptEventType = "SONG_RETRY"; - /** - * Called when the player releases a key on the keyboard. - * - * This event is not cancelable. - */ - public static inline final KEY_UP:ScriptEventType = "KEY_UP"; + /** + * Called when the player pushes down any key on the keyboard. + * + * This event is not cancelable. + */ + public static inline final KEY_DOWN:ScriptEventType = "KEY_DOWN"; - /** - * Called when the game has finished loading the notes from JSON. - * This allows modders to mutate the notes before they are used in the song. - * - * This event is not cancelable. - */ - public static inline final SONG_LOADED:ScriptEventType = "SONG_LOADED"; + /** + * Called when the player releases a key on the keyboard. + * + * This event is not cancelable. + */ + public static inline final KEY_UP:ScriptEventType = "KEY_UP"; - /** - * Called when the game is about to switch the current FlxState. - * - * This event is not cancelable. - */ - public static inline final STATE_CHANGE_BEGIN:ScriptEventType = "STATE_CHANGE_BEGIN"; + /** + * Called when the game has finished loading the notes from JSON. + * This allows modders to mutate the notes before they are used in the song. + * + * This event is not cancelable. + */ + public static inline final SONG_LOADED:ScriptEventType = "SONG_LOADED"; - /** - * Called when the game has finished switching the current FlxState. - * - * This event is not cancelable. - */ - public static inline final STATE_CHANGE_END:ScriptEventType = "STATE_CHANGE_END"; + /** + * Called when the game is about to switch the current FlxState. + * + * This event is not cancelable. + */ + public static inline final STATE_CHANGE_BEGIN:ScriptEventType = "STATE_CHANGE_BEGIN"; - /** - * Called when the game is about to open a new FlxSubState. - * - * This event is not cancelable. - */ - public static inline final SUBSTATE_OPEN_BEGIN:ScriptEventType = "SUBSTATE_OPEN_BEGIN"; + /** + * Called when the game has finished switching the current FlxState. + * + * This event is not cancelable. + */ + public static inline final STATE_CHANGE_END:ScriptEventType = "STATE_CHANGE_END"; - /** - * Called when the game has finished opening a new FlxSubState. - * - * This event is not cancelable. - */ - public static inline final SUBSTATE_OPEN_END:ScriptEventType = "SUBSTATE_OPEN_END"; + /** + * Called when the game is about to open a new FlxSubState. + * + * This event is not cancelable. + */ + public static inline final SUBSTATE_OPEN_BEGIN:ScriptEventType = "SUBSTATE_OPEN_BEGIN"; - /** - * Called when the game is about to close the current FlxSubState. - * - * This event is not cancelable. - */ - public static inline final SUBSTATE_CLOSE_BEGIN:ScriptEventType = "SUBSTATE_CLOSE_BEGIN"; + /** + * Called when the game has finished opening a new FlxSubState. + * + * This event is not cancelable. + */ + public static inline final SUBSTATE_OPEN_END:ScriptEventType = "SUBSTATE_OPEN_END"; - /** - * Called when the game has finished closing the current FlxSubState. - * - * This event is not cancelable. - */ - public static inline final SUBSTATE_CLOSE_END:ScriptEventType = "SUBSTATE_CLOSE_END"; + /** + * Called when the game is about to close the current FlxSubState. + * + * This event is not cancelable. + */ + public static inline final SUBSTATE_CLOSE_BEGIN:ScriptEventType = "SUBSTATE_CLOSE_BEGIN"; - /** - * Called when the game is exiting the current FlxState. - * - * This event is not cancelable. - */ - /** - * If true, the behavior associated with this event can be prevented. - * For example, cancelling COUNTDOWN_START should prevent the countdown from starting, - * until another script restarts it, or cancelling NOTE_HIT should cause the note to be missed. - */ - public var cancelable(default, null):Bool; + /** + * Called when the game has finished closing the current FlxSubState. + * + * This event is not cancelable. + */ + public static inline final SUBSTATE_CLOSE_END:ScriptEventType = "SUBSTATE_CLOSE_END"; - /** - * The type associated with the event. - */ - public var type(default, null):ScriptEventType; + /** + * Called when the game is exiting the current FlxState. + * + * This event is not cancelable. + */ + /** + * If true, the behavior associated with this event can be prevented. + * For example, cancelling COUNTDOWN_START should prevent the countdown from starting, + * until another script restarts it, or cancelling NOTE_HIT should cause the note to be missed. + */ + public var cancelable(default, null):Bool; - /** - * Whether the event should continue to be triggered on additional targets. - */ - public var shouldPropagate(default, null):Bool; + /** + * The type associated with the event. + */ + public var type(default, null):ScriptEventType; - /** - * Whether the event has been canceled by one of the scripts that received it. - */ - public var eventCanceled(default, null):Bool; + /** + * Whether the event should continue to be triggered on additional targets. + */ + public var shouldPropagate(default, null):Bool; - public function new(type:ScriptEventType, cancelable:Bool = false):Void - { - this.type = type; - this.cancelable = cancelable; - this.eventCanceled = false; - this.shouldPropagate = true; - } + /** + * Whether the event has been canceled by one of the scripts that received it. + */ + public var eventCanceled(default, null):Bool; - /** - * Call this function on a cancelable event to cancel the associated behavior. - * For example, cancelling COUNTDOWN_START will prevent the countdown from starting. - */ - public function cancelEvent():Void - { - if (cancelable) - { - eventCanceled = true; - } - } + public function new(type:ScriptEventType, cancelable:Bool = false):Void + { + this.type = type; + this.cancelable = cancelable; + this.eventCanceled = false; + this.shouldPropagate = true; + } - public function cancel():Void - { - // This typo happens enough that I just added this. - cancelEvent(); - } + /** + * Call this function on a cancelable event to cancel the associated behavior. + * For example, cancelling COUNTDOWN_START will prevent the countdown from starting. + */ + public function cancelEvent():Void + { + if (cancelable) + { + eventCanceled = true; + } + } - /** - * Call this function to stop any other Scripteds from receiving the event. - */ - public function stopPropagation():Void - { - shouldPropagate = false; - } + public function cancel():Void + { + // This typo happens enough that I just added this. + cancelEvent(); + } - public function toString():String - { - return 'ScriptEvent(type=$type, cancelable=$cancelable)'; - } + /** + * Call this function to stop any other Scripteds from receiving the event. + */ + public function stopPropagation():Void + { + shouldPropagate = false; + } + + public function toString():String + { + return 'ScriptEvent(type=$type, cancelable=$cancelable)'; + } } /** @@ -288,29 +296,29 @@ class ScriptEvent */ 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; + /** + * The note associated with this event. + * You cannot replace it, but you can edit it. + */ + public var note(default, null):Note; - /** - * The combo count as it is with this event. - * Will be (combo) on miss events and (combo + 1) on hit events (the stored combo count won't update if the event is cancelled). - */ - public var comboCount(default, null):Int; + /** + * The combo count as it is with this event. + * Will be (combo) on miss events and (combo + 1) on hit events (the stored combo count won't update if the event is cancelled). + */ + public var comboCount(default, null):Int; - public function new(type:ScriptEventType, note:Note, comboCount:Int = 0, cancelable:Bool = false):Void - { - super(type, cancelable); - this.note = note; - this.comboCount = comboCount; - } + public function new(type:ScriptEventType, note:Note, comboCount:Int = 0, cancelable:Bool = false):Void + { + super(type, cancelable); + this.note = note; + this.comboCount = comboCount; + } - public override function toString():String - { - return 'NoteScriptEvent(type=' + type + ', cancelable=' + cancelable + ', note=' + note + ', comboCount=' + comboCount + ')'; - } + public override function toString():String + { + return 'NoteScriptEvent(type=' + type + ', cancelable=' + cancelable + ', note=' + note + ', comboCount=' + comboCount + ')'; + } } /** @@ -318,52 +326,81 @@ class NoteScriptEvent extends ScriptEvent */ class GhostMissNoteScriptEvent extends ScriptEvent { - /** - * The direction that was mistakenly pressed. - */ - public var dir(default, null):NoteDir; + /** + * The direction that was mistakenly pressed. + */ + public var dir(default, null):NoteDir; - /** - * Whether there was a note within judgement range when this ghost note was pressed. - */ - public var hasPossibleNotes(default, null):Bool; + /** + * Whether there was a note within judgement range when this ghost note was pressed. + */ + public var hasPossibleNotes(default, null):Bool; - /** - * How much health should be lost when this ghost note is pressed. - * Remember that max health is 2.00. - */ - public var healthChange(default, default):Float; + /** + * How much health should be lost when this ghost note is pressed. + * Remember that max health is 2.00. + */ + public var healthChange(default, default):Float; - /** - * How much score should be lost when this ghost note is pressed. - */ - public var scoreChange(default, default):Int; + /** + * How much score should be lost when this ghost note is pressed. + */ + public var scoreChange(default, default):Int; - /** - * Whether to play the record scratch sound. - */ - public var playSound(default, default):Bool; + /** + * Whether to play the record scratch sound. + */ + public var playSound(default, default):Bool; - /** - * Whether to play the miss animation on the player. - */ - public var playAnim(default, default):Bool; + /** + * Whether to play the miss animation on the player. + */ + public var playAnim(default, default):Bool; - public function new(dir:NoteDir, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void - { - super(ScriptEvent.NOTE_GHOST_MISS, true); - this.dir = dir; - this.hasPossibleNotes = hasPossibleNotes; - this.healthChange = healthChange; - this.scoreChange = scoreChange; - this.playSound = true; - this.playAnim = true; - } + public function new(dir:NoteDir, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void + { + super(ScriptEvent.NOTE_GHOST_MISS, true); + this.dir = dir; + this.hasPossibleNotes = hasPossibleNotes; + this.healthChange = healthChange; + this.scoreChange = scoreChange; + this.playSound = true; + this.playAnim = true; + } - public override function toString():String - { - return 'GhostMissNoteScriptEvent(dir=' + dir + ', hasPossibleNotes=' + hasPossibleNotes + ')'; - } + public override function toString():String + { + return 'GhostMissNoteScriptEvent(dir=' + dir + ', hasPossibleNotes=' + hasPossibleNotes + ')'; + } +} + +/** + * An event that is fired when the song reaches an event. + */ +class SongEventScriptEvent extends ScriptEvent +{ + /** + * The note associated with this event. + * You cannot replace it, but you can edit it. + */ + public var event(default, null):funkin.play.song.SongData.SongEventData; + + /** + * The combo count as it is with this event. + * Will be (combo) on miss events and (combo + 1) on hit events (the stored combo count won't update if the event is cancelled). + */ + public var comboCount(default, null):Int; + + public function new(event:funkin.play.song.SongData.SongEventData):Void + { + super(ScriptEvent.SONG_EVENT, true); + this.event = event; + } + + public override function toString():String + { + return 'SongEventScriptEvent(event=' + event + ')'; + } } /** @@ -371,22 +408,22 @@ class GhostMissNoteScriptEvent extends ScriptEvent */ class UpdateScriptEvent extends ScriptEvent { - /** - * The note associated with this event. - * You cannot replace it, but you can edit it. - */ - public var elapsed(default, null):Float; + /** + * The note associated with this event. + * You cannot replace it, but you can edit it. + */ + public var elapsed(default, null):Float; - public function new(elapsed:Float):Void - { - super(ScriptEvent.UPDATE, false); - this.elapsed = elapsed; - } + public function new(elapsed:Float):Void + { + super(ScriptEvent.UPDATE, false); + this.elapsed = elapsed; + } - public override function toString():String - { - return 'UpdateScriptEvent(elapsed=$elapsed)'; - } + public override function toString():String + { + return 'UpdateScriptEvent(elapsed=$elapsed)'; + } } /** @@ -395,27 +432,27 @@ class UpdateScriptEvent extends ScriptEvent */ class SongTimeScriptEvent extends ScriptEvent { - /** - * The current beat of the song. - */ - public var beat(default, null):Int; + /** + * The current beat of the song. + */ + public var beat(default, null):Int; - /** - * The current step of the song. - */ - public var step(default, null):Int; + /** + * The current step of the song. + */ + public var step(default, null):Int; - public function new(type:ScriptEventType, beat:Int, step:Int):Void - { - super(type, true); - this.beat = beat; - this.step = step; - } + public function new(type:ScriptEventType, beat:Int, step:Int):Void + { + super(type, true); + this.beat = beat; + this.step = step; + } - public override function toString():String - { - return 'SongTimeScriptEvent(type=' + type + ', beat=' + beat + ', step=' + step + ')'; - } + public override function toString():String + { + return 'SongTimeScriptEvent(type=' + type + ', beat=' + beat + ', step=' + step + ')'; + } } /** @@ -424,21 +461,21 @@ class SongTimeScriptEvent extends ScriptEvent */ class CountdownScriptEvent extends ScriptEvent { - /** - * The current step of the countdown. - */ - public var step(default, null):CountdownStep; + /** + * The current step of the countdown. + */ + public var step(default, null):CountdownStep; - public function new(type:ScriptEventType, step:CountdownStep, cancelable = true):Void - { - super(type, cancelable); - this.step = step; - } + public function new(type:ScriptEventType, step:CountdownStep, cancelable = true):Void + { + super(type, cancelable); + this.step = step; + } - public override function toString():String - { - return 'CountdownScriptEvent(type=' + type + ', step=' + step + ')'; - } + public override function toString():String + { + return 'CountdownScriptEvent(type=' + type + ', step=' + step + ')'; + } } /** @@ -446,21 +483,21 @@ class CountdownScriptEvent extends ScriptEvent */ class KeyboardInputScriptEvent extends ScriptEvent { - /** - * The associated keyboard event. - */ - public var event(default, null):KeyboardEvent; + /** + * The associated keyboard event. + */ + public var event(default, null):KeyboardEvent; - public function new(type:ScriptEventType, event:KeyboardEvent):Void - { - super(type, false); - this.event = event; - } + public function new(type:ScriptEventType, event:KeyboardEvent):Void + { + super(type, false); + this.event = event; + } - public override function toString():String - { - return 'KeyboardInputScriptEvent(type=' + type + ', event=' + event + ')'; - } + public override function toString():String + { + return 'KeyboardInputScriptEvent(type=' + type + ', event=' + event + ')'; + } } /** @@ -468,35 +505,35 @@ class KeyboardInputScriptEvent extends ScriptEvent */ 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; + /** + * The note associated with this event. + * You cannot replace it, but you can edit it. + */ + public var notes(default, set):Array; - public var id(default, null):String; + public var id(default, null):String; - public var difficulty(default, null):String; + public var difficulty(default, null):String; - function set_notes(notes:Array):Array - { - this.notes = notes; - return this.notes; - } + function set_notes(notes:Array):Array + { + this.notes = notes; + return this.notes; + } - public function new(id:String, difficulty:String, notes:Array):Void - { - super(ScriptEvent.SONG_LOADED, false); - this.id = id; - this.difficulty = difficulty; - this.notes = notes; - } + public function new(id:String, difficulty:String, notes:Array):Void + { + super(ScriptEvent.SONG_LOADED, false); + this.id = id; + this.difficulty = difficulty; + this.notes = notes; + } - public override function toString():String - { - var noteStr = notes == null ? 'null' : 'Array(' + notes.length + ')'; - return 'SongLoadScriptEvent(notes=$noteStr, id=$id, difficulty=$difficulty)'; - } + public override function toString():String + { + var noteStr = notes == null ? 'null' : 'Array(' + notes.length + ')'; + return 'SongLoadScriptEvent(notes=$noteStr, id=$id, difficulty=$difficulty)'; + } } /** @@ -504,21 +541,21 @@ class SongLoadScriptEvent extends ScriptEvent */ class StateChangeScriptEvent extends ScriptEvent { - /** - * The state the game is moving into. - */ - public var targetState(default, null):FlxState; + /** + * The state the game is moving into. + */ + public var targetState(default, null):FlxState; - public function new(type:ScriptEventType, targetState:FlxState, cancelable:Bool = false):Void - { - super(type, cancelable); - this.targetState = targetState; - } + public function new(type:ScriptEventType, targetState:FlxState, cancelable:Bool = false):Void + { + super(type, cancelable); + this.targetState = targetState; + } - public override function toString():String - { - return 'StateChangeScriptEvent(type=' + type + ', targetState=' + targetState + ')'; - } + public override function toString():String + { + return 'StateChangeScriptEvent(type=' + type + ', targetState=' + targetState + ')'; + } } /** @@ -526,21 +563,21 @@ class StateChangeScriptEvent extends ScriptEvent */ class SubStateScriptEvent extends ScriptEvent { - /** - * The state the game is moving into. - */ - public var targetState(default, null):FlxSubState; + /** + * The state the game is moving into. + */ + public var targetState(default, null):FlxSubState; - public function new(type:ScriptEventType, targetState:FlxSubState, cancelable:Bool = false):Void - { - super(type, cancelable); - this.targetState = targetState; - } + public function new(type:ScriptEventType, targetState:FlxSubState, cancelable:Bool = false):Void + { + super(type, cancelable); + this.targetState = targetState; + } - public override function toString():String - { - return 'SubStateScriptEvent(type=' + type + ', targetState=' + targetState + ')'; - } + public override function toString():String + { + return 'SubStateScriptEvent(type=' + type + ', targetState=' + targetState + ')'; + } } /** @@ -548,14 +585,14 @@ class SubStateScriptEvent extends ScriptEvent */ class PauseScriptEvent extends ScriptEvent { - /** - * Whether to use the Gitaroo Man pause. - */ - public var gitaroo(default, default):Bool; + /** + * Whether to use the Gitaroo Man pause. + */ + public var gitaroo(default, default):Bool; - public function new(gitaroo:Bool):Void - { - super(ScriptEvent.PAUSE, true); - this.gitaroo = gitaroo; - } + public function new(gitaroo:Bool):Void + { + super(ScriptEvent.PAUSE, true); + this.gitaroo = gitaroo; + } } diff --git a/source/funkin/modding/module/Module.hx b/source/funkin/modding/module/Module.hx index d337ad719..0d76c2c14 100644 --- a/source/funkin/modding/module/Module.hx +++ b/source/funkin/modding/module/Module.hx @@ -10,109 +10,111 @@ import funkin.modding.events.ScriptEvent; */ class Module implements IPlayStateScriptedClass implements IStateChangingScriptedClass { - /** - * Whether the module is currently active. - */ - public var active(default, set):Bool = true; + /** + * Whether the module is currently active. + */ + public var active(default, set):Bool = true; - function set_active(value:Bool):Bool - { - this.active = value; - return value; - } + function set_active(value:Bool):Bool + { + this.active = value; + return value; + } - public var moduleId(default, null):String = 'UNKNOWN'; + public var moduleId(default, null):String = 'UNKNOWN'; - /** - * Determines the order in which modules receive events. - * You can modify this to change the order in which a given module receives events. - * - * Priority 1 is processed before Priority 1000, etc. - */ - public var priority(default, set):Int; + /** + * Determines the order in which modules receive events. + * You can modify this to change the order in which a given module receives events. + * + * Priority 1 is processed before Priority 1000, etc. + */ + public var priority(default, set):Int; - function set_priority(value:Int):Int - { - this.priority = value; - @:privateAccess - ModuleHandler.reorderModuleCache(); - return value; - } + function set_priority(value:Int):Int + { + this.priority = value; + @:privateAccess + ModuleHandler.reorderModuleCache(); + return value; + } - /** - * Called when the module is initialized. - * It may not be safe to reference other modules here since they may not be loaded yet. - * - * NOTE: To make the module start inactive, call `this.active = false` in the constructor. - */ - public function new(moduleId:String, priority:Int = 1000):Void - { - this.moduleId = moduleId; - this.priority = priority; - } + /** + * Called when the module is initialized. + * It may not be safe to reference other modules here since they may not be loaded yet. + * + * NOTE: To make the module start inactive, call `this.active = false` in the constructor. + */ + public function new(moduleId:String, priority:Int = 1000):Void + { + this.moduleId = moduleId; + this.priority = priority; + } - public function toString() - { - return 'Module(' + this.moduleId + ')'; - } + public function toString() + { + return 'Module(' + this.moduleId + ')'; + } - // TODO: Half of these aren't actually being called!!!!!!! + // TODO: Half of these aren't actually being called!!!!!!! - public function onScriptEvent(event:ScriptEvent) {} + public function onScriptEvent(event:ScriptEvent) {} - /** - * Called when the module is first created. - * This happens before the title screen appears! - */ - public function onCreate(event:ScriptEvent) {} + /** + * Called when the module is first created. + * This happens before the title screen appears! + */ + public function onCreate(event:ScriptEvent) {} - /** - * Called when a module is destroyed. - * This currently only happens when reloading modules with F5. - */ - public function onDestroy(event:ScriptEvent) {} + /** + * Called when a module is destroyed. + * This currently only happens when reloading modules with F5. + */ + public function onDestroy(event:ScriptEvent) {} - public function onUpdate(event:UpdateScriptEvent) {} + public function onUpdate(event:UpdateScriptEvent) {} - public function onPause(event:PauseScriptEvent) {} + public function onPause(event:PauseScriptEvent) {} - public function onResume(event:ScriptEvent) {} + public function onResume(event:ScriptEvent) {} - public function onSongStart(event:ScriptEvent) {} + public function onSongStart(event:ScriptEvent) {} - public function onSongEnd(event:ScriptEvent) {} + public function onSongEnd(event:ScriptEvent) {} - public function onGameOver(event:ScriptEvent) {} + public function onGameOver(event:ScriptEvent) {} - public function onNoteHit(event:NoteScriptEvent) {} + public function onNoteHit(event:NoteScriptEvent) {} - public function onNoteMiss(event:NoteScriptEvent) {} + public function onNoteMiss(event:NoteScriptEvent) {} - public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} - public function onStepHit(event:SongTimeScriptEvent) {} + public function onStepHit(event:SongTimeScriptEvent) {} - public function onBeatHit(event:SongTimeScriptEvent) {} + public function onBeatHit(event:SongTimeScriptEvent) {} - public function onCountdownStart(event:CountdownScriptEvent) {} + public function onSongEvent(event:SongEventScriptEvent) {} - public function onCountdownStep(event:CountdownScriptEvent) {} + public function onCountdownStart(event:CountdownScriptEvent) {} - public function onCountdownEnd(event:CountdownScriptEvent) {} + public function onCountdownStep(event:CountdownScriptEvent) {} - public function onSongLoaded(event:SongLoadScriptEvent) {} + public function onCountdownEnd(event:CountdownScriptEvent) {} - public function onStateChangeBegin(event:StateChangeScriptEvent) {} + public function onSongLoaded(event:SongLoadScriptEvent) {} - public function onStateChangeEnd(event:StateChangeScriptEvent) {} + public function onStateChangeBegin(event:StateChangeScriptEvent) {} - public function onSubstateOpenBegin(event:SubStateScriptEvent) {} + public function onStateChangeEnd(event:StateChangeScriptEvent) {} - public function onSubstateOpenEnd(event:SubStateScriptEvent) {} + public function onSubstateOpenBegin(event:SubStateScriptEvent) {} - public function onSubstateCloseBegin(event:SubStateScriptEvent) {} + public function onSubstateOpenEnd(event:SubStateScriptEvent) {} - public function onSubstateCloseEnd(event:SubStateScriptEvent) {} + public function onSubstateCloseBegin(event:SubStateScriptEvent) {} - public function onSongRetry(event:ScriptEvent) {} + public function onSubstateCloseEnd(event:SubStateScriptEvent) {} + + public function onSongRetry(event:ScriptEvent) {} } diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx index 93ac7bc66..51d723d0d 100644 --- a/source/funkin/modding/module/ModuleHandler.hx +++ b/source/funkin/modding/module/ModuleHandler.hx @@ -11,132 +11,137 @@ import funkin.modding.module.ScriptedModule; */ class ModuleHandler { - static final moduleCache:Map = new Map(); - static var modulePriorityOrder:Array = []; + static final moduleCache:Map = new Map(); + static var modulePriorityOrder:Array = []; - /** - * Parses and preloads the game's stage data and scripts when the game starts. - * - * If you want to force stages to be reloaded, you can just call this function again. - */ - public static function loadModuleCache():Void - { - // Clear any stages that are cached if there were any. - clearModuleCache(); - trace("[MODULEHANDLER] Loading module cache..."); + /** + * Parses and preloads the game's stage data and scripts when the game starts. + * + * If you want to force stages to be reloaded, you can just call this function again. + */ + public static function loadModuleCache():Void + { + // Clear any stages that are cached if there were any. + clearModuleCache(); + trace("[MODULEHANDLER] Loading module cache..."); - var scriptedModuleClassNames:Array = ScriptedModule.listScriptClasses(); - trace(' Instantiating ${scriptedModuleClassNames.length} modules...'); - for (moduleCls in scriptedModuleClassNames) - { - var module:Module = ScriptedModule.init(moduleCls, moduleCls); - if (module != null) - { - trace(' Loaded module: ${moduleCls}'); + var scriptedModuleClassNames:Array = ScriptedModule.listScriptClasses(); + trace(' Instantiating ${scriptedModuleClassNames.length} modules...'); + for (moduleCls in scriptedModuleClassNames) + { + var module:Module = ScriptedModule.init(moduleCls, moduleCls); + if (module != null) + { + trace(' Loaded module: ${moduleCls}'); - // Then store it. - addToModuleCache(module); - } - else - { - trace(' Failed to instantiate module: ${moduleCls}'); - } - } - reorderModuleCache(); + // Then store it. + addToModuleCache(module); + } + else + { + trace(' Failed to instantiate module: ${moduleCls}'); + } + } + reorderModuleCache(); - trace("[MODULEHANDLER] Module cache loaded."); - } + trace("[MODULEHANDLER] Module cache loaded."); + } - public static function buildModuleCallbacks():Void - { - FlxG.signals.postStateSwitch.add(onStateSwitchComplete); - } + public static function buildModuleCallbacks():Void + { + FlxG.signals.postStateSwitch.add(onStateSwitchComplete); + } - static function onStateSwitchComplete():Void - { - callEvent(new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_END, FlxG.state, true)); - } + static function onStateSwitchComplete():Void + { + callEvent(new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_END, FlxG.state, true)); + } - static function addToModuleCache(module:Module):Void - { - moduleCache.set(module.moduleId, module); - } + static function addToModuleCache(module:Module):Void + { + moduleCache.set(module.moduleId, module); + } - static function reorderModuleCache():Void - { - modulePriorityOrder = moduleCache.keys().array(); + static function reorderModuleCache():Void + { + modulePriorityOrder = moduleCache.keys().array(); - modulePriorityOrder.sort(function(a:String, b:String):Int - { - var aModule:Module = moduleCache.get(a); - var bModule:Module = moduleCache.get(b); + modulePriorityOrder.sort(function(a:String, b:String):Int + { + var aModule:Module = moduleCache.get(a); + var bModule:Module = moduleCache.get(b); - if (aModule.priority != bModule.priority) - { - return aModule.priority - bModule.priority; - } - else - { - // Sort alphabetically. Yes that's how this works. - return a > b ? 1 : -1; - } - }); - } + if (aModule.priority != bModule.priority) + { + return aModule.priority - bModule.priority; + } + else + { + // Sort alphabetically. Yes that's how this works. + return a > b ? 1 : -1; + } + }); + } - public static function getModule(moduleId:String):Module - { - return moduleCache.get(moduleId); - } + public static function getModule(moduleId:String):Module + { + return moduleCache.get(moduleId); + } - public static function activateModule(moduleId:String):Void - { - var module:Module = getModule(moduleId); - if (module != null) - { - module.active = true; - } - } + public static function activateModule(moduleId:String):Void + { + var module:Module = getModule(moduleId); + if (module != null) + { + module.active = true; + } + } - public static function deactivateModule(moduleId:String):Void - { - var module:Module = getModule(moduleId); - if (module != null) - { - module.active = false; - } - } + public static function deactivateModule(moduleId:String):Void + { + var module:Module = getModule(moduleId); + if (module != null) + { + module.active = false; + } + } - /** - * Clear the module cache, forcing all modules to call shutdown events. - */ - public static function clearModuleCache():Void - { - if (moduleCache != null) - { - var event = new ScriptEvent(ScriptEvent.DESTROY, false); + /** + * Clear the module cache, forcing all modules to call shutdown events. + */ + public static function clearModuleCache():Void + { + if (moduleCache != null) + { + var event = new ScriptEvent(ScriptEvent.DESTROY, false); - // Note: Ignore stopPropagation() - for (key => value in moduleCache) - { - ScriptEventDispatcher.callEvent(value, event); - moduleCache.remove(key); - } + // Note: Ignore stopPropagation() + for (key => value in moduleCache) + { + ScriptEventDispatcher.callEvent(value, event); + moduleCache.remove(key); + } - moduleCache.clear(); - modulePriorityOrder = []; - } - } + moduleCache.clear(); + modulePriorityOrder = []; + } + } - public static function callEvent(event:ScriptEvent):Void - { - for (moduleId in modulePriorityOrder) - { - var module:Module = moduleCache.get(moduleId); - // The module needs to be active to receive events. - if (module != null && module.active) - { - ScriptEventDispatcher.callEvent(module, event); - } - } - } + public static function callEvent(event:ScriptEvent):Void + { + for (moduleId in modulePriorityOrder) + { + var module:Module = moduleCache.get(moduleId); + // The module needs to be active to receive events. + if (module != null && module.active) + { + ScriptEventDispatcher.callEvent(module, event); + } + } + } + + public static inline function callOnCreate():Void + { + callEvent(new ScriptEvent(ScriptEvent.CREATE, false)); + } } diff --git a/source/funkin/noteStuff/NoteUtil.hx b/source/funkin/noteStuff/NoteUtil.hx index 054ec2fef..c4d552b8b 100644 --- a/source/funkin/noteStuff/NoteUtil.hx +++ b/source/funkin/noteStuff/NoteUtil.hx @@ -13,87 +13,87 @@ import openfl.Assets; */ 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)); - } + /** + * 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; - } + 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(); + /** + * 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] = []; + 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}); - } + songData[songEvent.t].push({songEventType: songEvent.e, value: songEvent.v, activated: false}); + } - trace("FINISH SONG EVENTS!"); - trace(songData); + trace("FINISH SONG EVENTS!"); + trace(songData); - return 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; - } - } - } - } - } + 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; + var songEventType:SongEventType; + var value:Dynamic; + var activated:Bool; } typedef SongEvent = { - var t:Int; - var e:SongEventType; - var v:Dynamic; + var t:Int; + var e:SongEventType; + var v:Dynamic; } enum abstract SongEventType(String) { - var FocusCamera; - var PlayCharAnim; - var Trace; + var FocusCamera; + var PlayCharAnim; + var Trace; } diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index f5fff3cf5..5c166d43d 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -1,5 +1,7 @@ package funkin.play; +import funkin.play.song.SongData.SongEventData; +import funkin.play.event.SongEvent.SongEventParser; import flixel.FlxCamera; import flixel.FlxObject; import flixel.FlxSprite; @@ -29,7 +31,6 @@ import funkin.play.Strumline.StrumlineArrow; import funkin.play.Strumline.StrumlineStyle; import funkin.play.character.BaseCharacter; import funkin.play.character.CharacterData; -import funkin.play.event.SongEvent; import funkin.play.scoring.Scoring; import funkin.play.song.Song; import funkin.play.song.SongData.SongNoteData; @@ -49,2612 +50,2608 @@ import Discord.DiscordClient; class PlayState extends MusicBeatState { - /** - * STATIC VARIABLES - * Static variables should be used for information that must be persisted between states or between resets, - * such as the active song or song playlist. - */ - /** - * The currently active PlayState. - * Since there is only one PlayState in existance at a time, we can use a singleton. - */ - public static var instance:PlayState = null; - - /** - * The currently active song. Includes data about what stage should be used, what characters, - * and the notes to be played. - */ - public static var currentSong:SwagSong = null; - - public static var currentSong_NEW:Song = null; - - /** - * Whether the game is currently in Story Mode. If false, we are in Free Play Mode. - */ - public static var isStoryMode:Bool = false; - - /** - * Whether the game is currently in Practice Mode. - * If true, player will not lose gain or lose score from notes. - */ - public static var isPracticeMode:Bool = false; - - /** - * Whether the game is currently in a cutscene, and gameplay should be stopped. - */ - public static var isInCutscene:Bool = false; - - /** - * Whether the game is currently in the countdown before the song resumes. - */ - public static var isInCountdown:Bool = false; - - /** - * Gets set to true when the PlayState needs to reset (player opted to restart or died). - * Gets disabled once resetting happens. - */ - public static var needsReset:Bool = false; - - /** - * The current "Blueball Counter" to display in the pause menu. - * Resets when you beat a song or go back to the main menu. - */ - public static var deathCounter:Int = 0; - - /** - * The default camera zoom level. The camera lerps back to this after zooming in. - * Defaults to 1.05 but may be larger or smaller depending on the current stage. - */ - public static var defaultCameraZoom:Float = 1.05; - - /** - * Used to persist the position of the `cameraFollowPosition` between resets. - */ - private static var previousCameraFollowPoint:FlxObject = null; - - /** - * PUBLIC INSTANCE VARIABLES - * Public instance variables should be used for information that must be reset or dereferenced - * every time the state is reset, such as the currently active stage, but may need to be accessed externally. - */ - /** - * The currently active Stage. This is the object containing all the props. - */ - public var currentStage:Stage = null; - - 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:String = ''; - - /** - * The player's current health. - * The default maximum health is 2.0, and the default starting health is 1.0. - */ - public var health:Float = 1; - - /** - * The player's current score. - */ - 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. - * - * 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. - */ - public var cameraFollowPoint:FlxSprite = new FlxSprite(0, 0); - - /** - * 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. - */ - private var inactiveNotes:Array; - - private var songEvents:Array; - - /** - * If true, the player is allowed to pause the game. - * Disabled during the ending of a song. - */ - private var mayPauseGame:Bool = true; - - /** - * The displayed value of the player's health. - * Used to provide smooth animations based on linear interpolation of the player's health. - */ - private var healthLerp:Float = 1; - - /** - * Forcibly disables all update logic while the game moves back to the Menu state. - * This is used only when a critical error occurs and the game cannot continue. - */ - private var criticalFailure:Bool = false; - - /** - * RENDER OBJECTS - */ - /** - * The SpriteGroup containing the notes that are currently on the screen or are about to be on the screen. - */ - private var activeNotes:FlxTypedGroup = null; - - /** - * The FlxText which displays the current score. - */ - private var scoreText:FlxText; - - /** - * The bar which displays the player's health. - * Dynamically updated based on the value of `healthLerp` (which is based on `health`). - */ - public var healthBar:FlxBar; - - /** - * The background image used for the health bar. - * Emma says the image is slightly skewed so I'm leaving it as an image instead of a `createGraphic`. - */ - public var healthBarBG:FlxSprite; - - /** - * The health icon representing the player. - */ - public var iconP1:HealthIcon; - - /** - * The health icon representing the opponent. - */ - public var iconP2:HealthIcon; - - /** - * The sprite group containing active player's strumline notes. - */ - public var playerStrumline:Strumline; - - /** - * The sprite group containing opponent's strumline notes. - */ - public var enemyStrumline:Strumline; - - /** - * The camera which contains, and controls visibility of, the user interface elements. - */ - public var camHUD:FlxCamera; - - /** - * The camera which contains, and controls visibility of, the stage and characters. - */ - public var camGame:FlxCamera; - - /** - * PROPERTIES - */ - /** - * If a substate is rendering over the PlayState, it is paused and normal update logic is skipped. - * Examples include: - * - The Pause screen is open. - * - The Game Over screen is open. - * - The Chart Editor screen is open. - */ - private var isGamePaused(get, never):Bool; - - function get_isGamePaused():Bool - { - // Note: If there is a substate which requires the game to act unpaused, - // this should be changed to include something like `&& Std.isOfType()` - return this.subState != null; - } - - // TODO: Reorganize these variables (maybe there should be a separate class like Conductor just to hold them?) - public static var storyWeek:Int = 0; - public static var storyPlaylist:Array = []; - public static var storyDifficulty:Int = 1; - public static var storyDifficulty_NEW:String = "normal"; - public static var seenCutscene:Bool = false; - public static var campaignScore:Int = 0; - - private var vocals:VoicesGroup; - private var vocalsFinished:Bool = false; - - private var camZooming:Bool = false; - private var gfSpeed:Int = 1; - // private var combo:Int = 0; - private var generatedMusic:Bool = false; - private var startingSong:Bool = false; - - var dialogue:Array; - var talking:Bool = true; - var doof:DialogueBox; - 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 - - override public function create() - { - super.create(); - - if (currentSong == null && currentSong_NEW == null) - { - criticalFailure = true; - - lime.app.Application.current.window.alert("There was a critical error while accessing the selected song. Click OK to return to the main menu.", - "Error loading PlayState"); - FlxG.switchState(new MainMenuState()); - return; - } - - instance = this; - - if (currentSong_NEW != null) - { - // TODO: Do this in the loading state. - currentSong_NEW.cacheCharts(true); - } - - // 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; - - // Reduce physics accuracy (who cares!!!) to improve animation quality. - FlxG.fixedTimestep = false; - - // This state receives update() even when a substate is active. - this.persistentUpdate = true; - // This state receives draw calls even when a substate is active. - this.persistentDraw = true; - - // Stop any pre-existing music. - if (FlxG.sound.music != null) - FlxG.sound.music.stop(); - - // Prepare the current song to be played. - if (currentChart != null) - { - currentChart.cacheInst(); - currentChart.cacheVocals(); - } - else - { - FlxG.sound.cache(Paths.inst(currentSong.song)); - FlxG.sound.cache(Paths.voices(currentSong.song)); - } - - // Initialize stage stuff. - initCameras(); - - if (currentSong == null && currentSong_NEW == null) - { - currentSong = SongLoad.loadFromJson('tutorial'); - } - - if (currentSong_NEW != null) - { - Conductor.mapTimeChanges(currentChart.timeChanges); - // Conductor.bpm = currentChart.getStartingBPM(); - - // TODO: Support for dialog. - } - else - { - Conductor.mapBPMChanges(currentSong); - // Conductor.bpm = currentSong.bpm; - - switch (currentSong.song.toLowerCase()) - { - case 'senpai': - dialogue = CoolUtil.coolTextFile(Paths.txt('songs/senpai/senpaiDialogue')); - case 'roses': - dialogue = CoolUtil.coolTextFile(Paths.txt('songs/roses/rosesDialogue')); - case 'thorns': - dialogue = CoolUtil.coolTextFile(Paths.txt('songs/thorns/thornsDialogue')); - } - } - - Conductor.update(-5000); - - if (dialogue != null) - { - doof = new DialogueBox(false, dialogue); - doof.scrollFactor.set(); - doof.finishThing = startCountdown; - doof.cameras = [camHUD]; - } - - // 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); - - initStage(); - initCharacters(); - #if discord_rpc - initDiscord(); - #end - - // Configure camera follow point. - if (previousCameraFollowPoint != null) - { - cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y); - previousCameraFollowPoint = null; - } - add(cameraFollowPoint); - - comboPopUps = new PopUpStuff(); - comboPopUps.cameras = [camHUD]; - add(comboPopUps); - - grpNoteSplashes = new FlxTypedGroup(); - - var noteSplash:NoteSplash = new NoteSplash(100, 100, 0); - grpNoteSplashes.add(noteSplash); - noteSplash.alpha = 0.1; - - add(grpNoteSplashes); - - if (currentSong_NEW != null) - { - generateSong_NEW(); - } - else - { - 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); - - // 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 (SONG.song == 'South') - // FlxG.camera.alpha = 0.7; - // UI_camera.zoom = 1; - - // cameras = [FlxG.cameras.list[1]]; - startingSong = true; - - if (isStoryMode && !seenCutscene) - { - seenCutscene = true; - - switch (currentSong.song.toLowerCase()) - { - case "winter-horrorland": - VanillaCutscenes.playHorrorStartCutscene(); - case 'senpai' | 'roses' | 'thorns': - schoolIntro(doof); // doof is assumed to be non-null, lol! - case 'ugh': - VanillaCutscenes.playUghCutscene(); - case 'stress': - VanillaCutscenes.playStressCutscene(); - case 'guns': - VanillaCutscenes.playGunsCutscene(); - default: - // VanillaCutscenes will call startCountdown later. - // TODO: Alternatively: make a song script that allows startCountdown to be called, - // then cancels the countdown, hides the strumline, plays the cutscene, - // then calls Countdown.performCountdown() - startCountdown(); - } - } - else - { - startCountdown(); - } - - #if debug - this.rightWatermarkText.text = Constants.VERSION; - #end - } - - function get_currentChart():SongDifficulty - { - if (currentSong_NEW == null || storyDifficulty_NEW == null) - return null; - return currentSong_NEW.getDifficulty(storyDifficulty_NEW); - } - - /** - * Initializes the game and HUD cameras. - */ - function initCameras() - { - // Configure the default camera zoom level. - defaultCameraZoom = FlxCamera.defaultZoom * 1.05; - - camGame = new SwagCamera(); - camHUD = new FlxCamera(); - camHUD.bgColor.alpha = 0; - - FlxG.cameras.reset(camGame); - FlxG.cameras.add(camHUD, false); - } - - function initStage() - { - if (currentSong_NEW != null) - { - initStage_NEW(); - return; - } - - // TODO: Move stageId to the song file. - switch (currentSong.song.toLowerCase()) - { - case 'spookeez' | 'monster' | 'south': - currentStageId = "spookyMansion"; - case 'pico' | 'blammed' | 'philly': - currentStageId = 'phillyTrain'; - case "milf" | 'satin-panties' | 'high': - currentStageId = 'limoRide'; - case "cocoa" | 'eggnog': - currentStageId = 'mallXmas'; - case 'winter-horrorland': - currentStageId = 'mallEvil'; - case 'senpai' | 'roses': - currentStageId = 'school'; - case "darnell" | "lit-up" | "2hot": - currentStageId = 'phillyStreets'; - // currentStageId = 'pyro'; - case "blazin": - currentStageId = 'phillyBlazin'; - // currentStageId = 'pyro'; - case 'pyro': - currentStageId = 'pyro'; - case 'thorns': - currentStageId = 'schoolEvil'; - case 'guns' | 'stress' | 'ugh': - currentStageId = 'tankmanBattlefield'; - default: - currentStageId = "mainStage"; - } - // Loads the relevant stage based on its ID. - loadStage(currentStageId); - } - - function initStage_NEW() - { - if (currentChart == null) - { - trace('Song difficulty could not be loaded.'); - } - - if (currentChart.stage != null && currentChart.stage != '') - { - currentStageId = currentChart.stage; - } - else - { - currentStageId = SongValidator.DEFAULT_STAGE; - } - - loadStage(currentStageId); - } - - function initCharacters() - { - if (currentSong_NEW != null) - { - initCharacters_NEW(); - return; - } - - iconP1 = new HealthIcon(currentSong.player1, 0); - iconP1.y = healthBar.y - (iconP1.height / 2); - add(iconP1); - - iconP2 = new HealthIcon(currentSong.player2, 1); - iconP2.y = healthBar.y - (iconP2.height / 2); - add(iconP2); - - // - // GIRLFRIEND - // - - // TODO: Tie the GF version to the song data, not the stage ID or the current player. - var gfVersion:String = 'gf'; - - switch (currentStageId) - { - case 'pyro' | 'phillyStreets': - gfVersion = 'nene'; - case 'blazin': - gfVersion = ''; - case 'limoRide': - gfVersion = 'gf-car'; - case 'mallXmas' | 'mallEvil': - gfVersion = 'gf-christmas'; - case 'school' | 'schoolEvil': - gfVersion = 'gf-pixel'; - case 'tankmanBattlefield': - gfVersion = 'gf-tankmen'; - } - - if (currentSong.player1 == "pico") - gfVersion = "nene"; - - if (currentSong.song.toLowerCase() == 'stress') - gfVersion = 'pico-speaker'; - - if (currentSong.song.toLowerCase() == 'tutorial') - gfVersion = ''; - - // - // GIRLFRIEND - // - var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(gfVersion); - - if (girlfriend != null) - { - girlfriend.characterType = CharacterType.GF; - girlfriend.scrollFactor.set(0.95, 0.95); - if (gfVersion == 'pico-speaker') - { - girlfriend.x -= 50; - girlfriend.y -= 200; - } - } - else if (gfVersion != '') - { - trace('WARNING: Could not load girlfriend character with ID ${gfVersion}, skipping...'); - } - - // - // DAD - // - var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player2); - - if (dad != null) - { - dad.characterType = CharacterType.DAD; - } - - switch (currentSong.player2) - { - case 'gf': - if (isStoryMode) - { - cameraFollowPoint.x += 600; - tweenCamIn(); - } - } - - // - // BOYFRIEND - // - var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player1); - - if (boyfriend != null) - { - boyfriend.characterType = CharacterType.BF; - } - - if (currentStage != null) - { - // We're using Eric's stage handler. - // Characters get added to the stage, not the main scene. - if (girlfriend != null) - { - currentStage.addCharacter(girlfriend, GF); - } - - if (boyfriend != null) - { - currentStage.addCharacter(boyfriend, BF); - } - - if (dad != null) - { - currentStage.addCharacter(dad, DAD); - // Camera starts at dad. - cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y); - } - - // Redo z-indexes. - currentStage.refresh(); - } - } - - function initCharacters_NEW() - { - if (currentSong_NEW == 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 = currentChart.getPlayableChars(); - var currentPlayer = 'bf'; - - if (playableChars.length == 0) - { - trace('WARNING: No playable characters found for this song.'); - } - else if (playableChars.indexOf(currentPlayer) == -1) - { - currentPlayer = playableChars[0]; - } - - var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayer); - - // - // 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; - } - - // TODO: Cut out this code/make it generic. - switch (currentCharData.opponent) - { - case 'gf': - if (isStoryMode) - { - cameraFollowPoint.x += 600; - tweenCamIn(); - } - } - - // - // OPPONENT HEALTH ICON - // - iconP2 = new HealthIcon(currentCharData.opponent, 1); - iconP2.y = healthBar.y - (iconP2.height / 2); - add(iconP2); - - // - // BOYFRIEND - // - var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayer); - - if (boyfriend != null) - { - boyfriend.characterType = CharacterType.BF; - } - - // - // PLAYER HEALTH ICON - // - iconP1 = new HealthIcon(currentPlayer, 0); - iconP1.y = healthBar.y - (iconP1.height / 2); - 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 (boyfriend != null) - { - currentStage.addCharacter(boyfriend, BF); - } - - if (dad != null) - { - currentStage.addCharacter(dad, DAD); - // Camera starts at dad. - cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y); - } - - // 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() - { - // 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; - } - - super.debug_refreshModules(); - } - - /** - * Pauses music and vocals easily. - */ - public function pauseMusic() - { - 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) - { - 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. - defaultCameraZoom = currentStage.camZoom; - - // Add the stage to the scene. - this.add(currentStage); - } - } - - 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, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); - #end - } - - function schoolIntro(?dialogueBox:DialogueBox):Void - { - var black:FlxSprite = new FlxSprite(-100, -100).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK); - black.scrollFactor.set(); - add(black); - - var red:FlxSprite = new FlxSprite(-100, -100).makeGraphic(FlxG.width * 2, FlxG.height * 2, 0xFFff1b31); - red.scrollFactor.set(); - - var senpaiEvil:FlxSprite = new FlxSprite(); - senpaiEvil.frames = Paths.getSparrowAtlas('weeb/senpaiCrazy'); - senpaiEvil.animation.addByPrefix('idle', 'Senpai Pre Explosion', 24, false); - senpaiEvil.setGraphicSize(Std.int(senpaiEvil.width * Constants.PIXEL_ART_SCALE)); - senpaiEvil.scrollFactor.set(); - senpaiEvil.updateHitbox(); - senpaiEvil.screenCenter(); - senpaiEvil.x += senpaiEvil.width / 5; - - if (currentSong.song.toLowerCase() == 'roses' || currentSong.song.toLowerCase() == 'thorns') - { - remove(black); - - if (currentSong.song.toLowerCase() == 'thorns') - { - add(red); - camHUD.visible = false; - } - else - FlxG.sound.play(Paths.sound('ANGRY')); - // moved senpai angry noise in here to clean up cutscene switch case lol - } - - new FlxTimer().start(0.3, function(tmr:FlxTimer) - { - black.alpha -= 0.15; - - if (black.alpha > 0) - tmr.reset(0.3); - else - { - if (dialogueBox != null) - { - isInCutscene = true; - - if (currentSong.song.toLowerCase() == 'thorns') - { - add(senpaiEvil); - senpaiEvil.alpha = 0; - new FlxTimer().start(0.3, function(swagTimer:FlxTimer) - { - senpaiEvil.alpha += 0.15; - if (senpaiEvil.alpha < 1) - swagTimer.reset(); - else - { - senpaiEvil.animation.play('idle'); - FlxG.sound.play(Paths.sound('Senpai_Dies'), 1, false, null, true, function() - { - remove(senpaiEvil); - remove(red); - FlxG.camera.fade(FlxColor.WHITE, 0.01, true, function() - { - add(dialogueBox); - camHUD.visible = true; - }, true); - }); - new FlxTimer().start(3.2, function(deadTime:FlxTimer) - { - FlxG.camera.fade(FlxColor.WHITE, 1.6, false); - }); - } - }); - } - else - add(dialogueBox); - } - else - startCountdown(); - - remove(black); - } - }); - } - - function startSong():Void - { - dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START)); - - startingSong = false; - - previousFrameTime = FlxG.game.ticks; - - if (!isGamePaused) - { - // if (FlxG.sound.music != null) - // FlxG.sound.music.play(true); - // else - if (currentChart != null) - { - currentChart.playInst(1.0, false); - } - else - { - FlxG.sound.playMusic(Paths.inst(currentSong.song), 1, false); - } - } - - FlxG.sound.music.onComplete = endSong; - trace('Playing 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, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength); - #end - } - - private function generateSong():Void - { - // FlxG.log.add(ChartParser.parse()); - - Conductor.forceBPM(currentSong.bpm); - - currentSong.song = currentSong.song; - - if (currentSong.needsVoices) - vocals = VoicesGroup.build(currentSong.song, currentSong.voiceList); - else - vocals = VoicesGroup.build(currentSong.song, null); - - vocals.members[0].onComplete = function() - { - vocalsFinished = true; - }; - - trace(vocals); - - activeNotes = new FlxTypedGroup(); - activeNotes.zIndex = 1000; - add(activeNotes); - - regenNoteData(); - - generatedMusic = true; - } - - private function generateSong_NEW():Void - { - if (currentChart == null) - { - trace('Song difficulty could not be loaded.'); - } - - Conductor.forceBPM(currentChart.getStartingBPM()); - - // TODO: Fix grouped vocals - vocals = currentChart.buildVocals(); - vocals.members[0].onComplete = function() - { - vocalsFinished = true; - } - - // Create the rendered note group. - activeNotes = new FlxTypedGroup(); - activeNotes.zIndex = 1000; - add(activeNotes); - - regenNoteData_NEW(); - - generatedMusic = true; - } - - function regenNoteData():Void - { - // resets combo, should prob put somewhere else! - Highscore.tallies.combo = 0; - Highscore.tallies = new Tallies(); - // make unspawn notes shit def empty - inactiveNotes = []; - - 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; - - // NEW SHIT - noteData = SongLoad.getSong(); - - for (section in noteData) - { - for (songNotes in section.sectionNotes) - { - var daStrumTime:Float = songNotes.strumTime; - // TODO: Replace 4 with strumlineSize - var daNoteData:Int = Std.int(songNotes.noteData % 4); - var gottaHitNote:Bool = section.mustHitSection; - - if (songNotes.highStakes) // noteData > 3 - gottaHitNote = !section.mustHitSection; - - var oldNote:Note; - if (inactiveNotes.length > 0) - oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)]; - else - oldNote = null; - - var strumlineStyle:StrumlineStyle = NORMAL; - - // TODO: Put this in the chart or something? - switch (currentStageId) - { - case 'school': - strumlineStyle = PIXEL; - case 'schoolEvil': - strumlineStyle = PIXEL; - } - - var swagNote:Note = new Note(daStrumTime, daNoteData, oldNote, false, strumlineStyle); - // swagNote.data = songNotes; - swagNote.data.sustainLength = songNotes.sustainLength; - swagNote.data.noteKind = songNotes.noteKind; - swagNote.scrollFactor.set(0, 0); - - var susLength:Float = swagNote.data.sustainLength; - - susLength = susLength / Conductor.stepCrochet; - inactiveNotes.push(swagNote); - - for (susNote in 0...Math.round(susLength)) - { - oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)]; - - var sustainNote:Note = new Note(daStrumTime + (Conductor.stepCrochet * susNote) + Conductor.stepCrochet, daNoteData, oldNote, true, - strumlineStyle); - sustainNote.data.noteKind = songNotes.noteKind; - sustainNote.scrollFactor.set(); - inactiveNotes.push(sustainNote); - - sustainNote.mustPress = gottaHitNote; - - if (sustainNote.mustPress) - sustainNote.x += FlxG.width / 2; // general offset - } - - // TODO: Replace 4 with strumlineSize - swagNote.mustPress = gottaHitNote; - - if (swagNote.mustPress) - { - if (playerStrumline != null) - { - swagNote.x = playerStrumline.getArrow(swagNote.data.noteData).x; - } - else - { - swagNote.x += FlxG.width / 2; // general offset - } - } - else - { - if (enemyStrumline != null) - { - swagNote.x = enemyStrumline.getArrow(swagNote.data.noteData).x; - } - else - { - // swagNote.x += FlxG.width / 2; // general offset - } - } - } - } - - inactiveNotes.sort(function(a:Note, b:Note):Int - { - return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b); - }); - } - - function regenNoteData_NEW():Void - { - Highscore.tallies.combo = 0; - Highscore.tallies = new Tallies(); - - // Reset song events. - songEvents = currentChart.getEvents(); - SongEventHandler.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) - { - if (playerStrumline != null) - { - // Align with the strumline arrow. - newNote.x = playerStrumline.getArrow(songNote.getDirection()).x; - } - else - { - // Assume strumline position. - newNote.x += FlxG.width / 2; - } - } - else - { - if (enemyStrumline != null) - { - newNote.x = enemyStrumline.getArrow(songNote.getDirection()).x; - } - else - { - // newNote.x += 0; - } - } - - inactiveNotes.push(newNote); - - oldNote = newNote; - - // Generate X sustain notes. - var sustainSections = Math.round(songNote.length / Conductor.stepCrochet); - for (noteIndex in 0...sustainSections) - { - var noteTimeOffset:Float = Conductor.stepCrochet + (Conductor.stepCrochet * 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) - { - if (playerStrumline != null) - { - // Align with the strumline arrow. - sustainNote.x = playerStrumline.getArrow(songNote.getDirection()).x; - } - else - { - // Assume strumline position. - sustainNote.x += FlxG.width / 2; - } - } - else - { - if (enemyStrumline != null) - { - sustainNote.x = enemyStrumline.getArrow(songNote.getDirection()).x; - } - else - { - // newNote.x += 0; - } - } - - inactiveNotes.push(sustainNote); - - oldNote = sustainNote; - } - } - - // Sorting is an expensive operation. - // Assume it was done in the chart file. - /** - inactiveNotes.sort(function(a:Note, b:Note):Int - { - return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b); - }); - **/ - } - - function tweenCamIn():Void - { - FlxTween.tween(FlxG.camera, {zoom: 1.3 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut}); - } - - #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(FlxG.sound.music.time + Conductor.offset); - - if (vocalsFinished) - return; - - vocals.time = FlxG.sound.music.time; - vocals.play(); - } - - override public function update(elapsed:Float) - { - super.update(elapsed); - - if (criticalFailure) - return; - - if (FlxG.keys.justPressed.U) - { - // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!! - persistentUpdate = false; - openSubState(new StageOffsetSubstate()); - } - - updateHealthBar(); - updateScoreText(); - - if (needsReset) - { - dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY)); - - resetCamera(); - - persistentUpdate = true; - persistentDraw = true; - - startingSong = true; - - FlxG.sound.music.pause(); - vocals.pause(); - - FlxG.sound.music.time = 0; - - currentStage.resetStage(); - - // Delete all notes and reset the arrays. - if (currentChart != null) - { - regenNoteData_NEW(); - } - else - { - regenNoteData(); - } - - health = 1; - songScore = 0; - Highscore.tallies.combo = 0; - Countdown.performCountdown(currentStageId.startsWith('school')); - - needsReset = false; - } - - #if !debug - perfectMode = false; - #else - if (FlxG.keys.justPressed.H) - camHUD.visible = !camHUD.visible; - #end - - // do this BEFORE super.update() so songPosition is accurate - if (startingSong) - { - if (isInCountdown) - { - Conductor.songPosition += elapsed * 1000; - if (Conductor.songPosition >= 0) - startSong(); - } - } - else - { - if (Paths.SOUND_EXT == 'mp3') - Conductor.offset = -13; // DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM! - - Conductor.update(FlxG.sound.music.time + Conductor.offset); - - 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; - } - } - } - - var androidPause:Bool = false; - - #if android - androidPause = FlxG.android.justPressed.BACK; - #end - - if ((controls.PAUSE || androidPause) && isInCountdown && mayPauseGame) - { - var event = new PauseScriptEvent(FlxG.random.bool(1 / 1000)); - - dispatchEvent(event); - - if (!event.eventCanceled) - { - // Pause updates while the substate is open, preventing the game state from advancing. - persistentUpdate = false; - // Enable drawing while the substate is open, allowing the game state to be shown behind the pause menu. - persistentDraw = true; - - // There is a 1/1000 change to use a special pause menu. - // This prevents the player from resuming, but that's the point. - // It's a reference to Gitaroo Man, which doesn't let you pause the game. - if (event.gitaroo) - { - FlxG.switchState(new GitarooPause()); - } - else - { - var boyfriendPos = currentStage.getBoyfriend().getScreenPosition(); - var pauseSubState = new PauseSubState(boyfriendPos.x, boyfriendPos.y); - openSubState(pauseSubState); - pauseSubState.camera = camHUD; - boyfriendPos.put(); - } - - #if discord_rpc - DiscordClient.changePresence(detailsPausedText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); - #end - } - } - - #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) - { - FlxG.switchState(new ChartingState()); - - #if discord_rpc - DiscordClient.changePresence("Chart Editor", null, null, true); - #end - } - - // 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 (health > 2.0) - health = 2.0; - if (health < 0.0) - health = 0.0; - - if (camZooming && subState == null) - { - FlxG.camera.zoom = FlxMath.lerp(defaultCameraZoom, FlxG.camera.zoom, 0.95); - camHUD.zoom = FlxMath.lerp(1 * FlxCamera.defaultZoom, camHUD.zoom, 0.95); - } - - FlxG.watch.addQuick("beatShit", Conductor.currentBeat); - FlxG.watch.addQuick("stepShit", Conductor.currentStep); - if (currentStage != null) - { - FlxG.watch.addQuick("bfAnim", currentStage.getBoyfriend().getCurrentAnimation()); - } - FlxG.watch.addQuick("songPos", Conductor.songPosition); - - if (currentSong != null && currentSong.song == 'Fresh') - { - switch (Conductor.currentBeat) - { - case 16: - camZooming = true; - gfSpeed = 2; - case 48: - gfSpeed = 1; - case 80: - gfSpeed = 2; - case 112: - gfSpeed = 1; - } - } - - if (!isInCutscene && !_exiting) - { - // RESET = Quick Game Over Screen - if (controls.RESET) - { - health = 0; - trace("RESET = True"); - } - - #if CAN_CHEAT // brandon's a pussy - if (controls.CHEAT) - { - health += 1; - trace("User is cheating!"); - } - #end - - if (health <= 0 && !isPracticeMode) - { - vocals.pause(); - FlxG.sound.music.pause(); - - deathCounter += 1; - - dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER)); - - // Disable updates, preventing animations in the background from playing. - persistentUpdate = false; - #if debug - if (FlxG.keys.pressed.THREE) - { - // TODO: Change the key or delete this? - // In debug builds, pressing 3 to kill the player makes the background transparent. - persistentDraw = true; - } - else - { - #end - persistentDraw = false; - #if debug - } - #end - - var gameOverSubstate = new GameOverSubstate(); - openSubState(gameOverSubstate); - - #if discord_rpc - // Game Over doesn't get his own variable because it's only used here - DiscordClient.changePresence("Game Over - " + detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); - #end - } - } - - while (inactiveNotes[0] != null && inactiveNotes[0].data.strumTime - Conductor.songPosition < 1800 / SongLoad.getSpeed()) - { - var dunceNote:Note = inactiveNotes[0]; - - if (dunceNote.mustPress && !dunceNote.isSustainNote) - Highscore.tallies.totalNotes++; - - activeNotes.add(dunceNote); - - inactiveNotes.shift(); - } - - 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 = playerStrumline.y + Note.swagWidth / 2; - - if (daNote.followsTime) - daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(SongLoad.getSpeed(), - 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) - { - if (currentSong != null && currentSong.song != 'Tutorial') - camZooming = true; - - 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; - } - else - { - // Volume of DAD. - if (currentSong != null && currentSong.needsVoices) - vocals.volume = 1; - } - } - - // WIP interpolation shit? Need to fix the pause issue - // daNote.y = (strumLine.y - (songTime - daNote.strumTime) * (0.45 * SONG.speed[SongLoad.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); - } - }); - } - - if (songEvents != null && songEvents.length > 0) - { - var songEventsToActivate:Array = SongEventHandler.queryEvents(songEvents, Conductor.songPosition); - - if (songEventsToActivate.length > 0) - trace('[EVENTS] Found ${songEventsToActivate.length} event(s) to activate.'); - - SongEventHandler.activateEvents(songEventsToActivate); - } - - if (!isInCutscene) - keyShit(true); - } - - 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 = 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 sec - */ - function changeSection(sec:Int):Void - { - FlxG.sound.music.pause(); - - var daBPM:Float = currentSong.bpm; - var daPos:Float = 0; - for (i in 0...(Std.int(curStep / 16 + sec))) - { - var section = SongLoad.getSong()[i]; - if (section == null) - continue; - if (section.changeBPM) - { - daBPM = SongLoad.getSong()[i].bpm; - } - daPos += 4 * (1000 * 60 / daBPM); - } - Conductor.songPosition = FlxG.sound.music.time = daPos; - Conductor.songPosition += Conductor.offset; - updateCurStep(); - resyncVocals(); - } - #end - - function endSong():Void - { - dispatchEvent(new ScriptEvent(ScriptEvent.SONG_END)); - - seenCutscene = false; - deathCounter = 0; - mayPauseGame = false; - FlxG.sound.music.volume = 0; - vocals.volume = 0; - if (currentSong != null && currentSong.validScore) - { - // crackhead double thingie, sets whether was new highscore, AND saves the song! - Highscore.tallies.isNewHighscore = Highscore.saveScore(currentSong.song, songScore, storyDifficulty); - - Highscore.saveCompletion(currentSong.song, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, storyDifficulty); - } - - if (isStoryMode) - { - campaignScore += songScore; - - storyPlaylist.remove(storyPlaylist[0]); - - if (storyPlaylist.length <= 0) - { - FlxG.sound.playMusic(Paths.music('freakyMenu')); - - transIn = FlxTransitionableState.defaultTransIn; - transOut = FlxTransitionableState.defaultTransOut; - - switch (storyWeek) - { - case 7: - FlxG.switchState(new VideoState()); - default: - FlxG.switchState(new StoryMenuState()); - } - - // if () - StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true; - - if (currentSong.validScore) - { - NGio.unlockMedal(60961); - Highscore.saveWeekScore(storyWeek, campaignScore, storyDifficulty); - } - - FlxG.save.data.weekUnlocked = StoryMenuState.weekUnlocked; - FlxG.save.flush(); - } - else - { - var difficulty:String = ""; - - if (storyDifficulty == 0) - difficulty = '-easy'; - - if (storyDifficulty == 2) - difficulty = '-hard'; - - trace('LOADING NEXT SONG'); - trace(storyPlaylist[0].toLowerCase() + difficulty); - - FlxTransitionableState.skipNextTransIn = true; - FlxTransitionableState.skipNextTransOut = true; - - FlxG.sound.music.stop(); - vocals.stop(); - - if (currentSong.song.toLowerCase() == 'eggnog') - { - var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom, - -FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK); - blackShit.scrollFactor.set(); - add(blackShit); - camHUD.visible = false; - isInCutscene = true; - - FlxG.sound.play(Paths.sound('Lights_Shut_off'), function() - { - // no camFollow so it centers on horror tree - currentSong = SongLoad.loadFromJson(storyPlaylist[0].toLowerCase() + difficulty, storyPlaylist[0]); - LoadingState.loadAndSwitchState(new PlayState()); - }); - } - else - { - previousCameraFollowPoint = cameraFollowPoint; - - currentSong = SongLoad.loadFromJson(storyPlaylist[0].toLowerCase() + difficulty, storyPlaylist[0]); - LoadingState.loadAndSwitchState(new PlayState()); - } - } - } - else - { - trace('WENT TO RESULTS SCREEN!'); - // unloadAssets(); - - camZooming = false; - - FlxG.camera.follow(PlayState.instance.currentStage.getGirlfriend(), null, 0.05); - FlxG.camera.targetOffset.y -= 350; - FlxG.camera.targetOffset.x += 20; - - FlxTween.tween(camHUD, {alpha: 0}, 0.6); - - new FlxTimer().start(0.8, _ -> - { - currentStage.getGirlfriend().animation.play("cheer"); - - FlxTween.tween(FlxG.camera, {zoom: 1200}, 1.1, { - ease: FlxEase.expoIn, - onComplete: _ -> - { - persistentUpdate = false; - vocals.stop(); - camHUD.alpha = 1; - var res:ResultState = new ResultState(); - res.camera = camHUD; - openSubState(res); - } - }); - }); - // FlxG.switchState(new FreeplayState()); - } - } - - // gives score and pops up rating - private function popUpScore(strumtime:Float, daNote:Note):Void - { - var noteDiff:Float = Math.abs(strumtime - Conductor.songPosition); - // boyfriend.playAnimation('hey'); - vocals.volume = 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; - comboPopUps.displayRating(daRating); - if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0) - comboPopUps.displayCombo(Highscore.tallies.combo); - } - - /* - function controlCamera() - { - if (currentStage == null) - return; - - switch (cameraFocusCharacter) - { - default: // null = No change - break; - case 0: // Boyfriend - var isFocusedOnBF = cameraFollowPoint.x == currentStage.getBoyfriend().cameraFocusPoint.x; - if (!isFocusedOnBF) - { - // Focus the camera on the player. - cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y); - } - case 1: // Dad - var isFocusedOnDad = cameraFollowPoint.x == currentStage.getDad().cameraFocusPoint.x; - if (!isFocusedOnDad) - { - cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y); - } - case 2: // Girlfriend - var isFocusedOnGF = cameraFollowPoint.x == currentStage.getGirlfriend().cameraFocusPoint.x; - if (!isFocusedOnGF) - { - cameraFollowPoint.setPosition(currentStage.getGirlfriend().cameraFocusPoint.x, currentStage.getGirlfriend().cameraFocusPoint.y); - } - } - - /* - if (cameraRightSide && !isFocusedOnBF) - { - // Focus the camera on the player. - cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y); - - // TODO: Un-hardcode this. - if (currentSong.song.toLowerCase() == 'tutorial') - FlxTween.tween(FlxG.camera, {zoom: 1 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut}); - } - else if (!cameraRightSide && !isFocusedOnDad) - { - // Focus the camera on the opponent. - cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y); - - // TODO: Un-hardcode this stuff. - if (currentStage.getDad().characterId == 'mom') - { - vocals.volume = 1; - } - - if (currentSong.song.toLowerCase() == 'tutorial') - tweenCamIn(); - } - */ - // } - - public function keyShit(test:Bool):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 - ]; - // 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); - - PlayState.instance.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'); - } - } - } - - /** - * 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; - - if (event.playSound) - { - vocals.volume = 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; - vocals.volume = 0; - - if (Highscore.tallies.combo != 0) - { - Highscore.tallies.combo = comboPopUps.displayCombo(0); - } - - 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.volume = 1; - - if (!note.isSustainNote) - { - note.kill(); - activeNotes.remove(note, true); - note.destroy(); - } - } - } - - override function stepHit():Bool - { - if (SongLoad.songData == null) - return false; - - // 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)) > 20 - || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 20) - { - 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); - } - - // Moving this code into the `beatHit` function allows for scripts and modules to control the camera better. - if (currentSong != null) - { - if (generatedMusic && SongLoad.getSong()[Std.int(Conductor.currentStep / 16)] != null) - { - // cameraRightSide = SongLoad.getSong()[Std.int(Conductor.currentStep / 16)].mustHitSection; - } - - if (SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)] != null) - { - if (SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)].changeBPM) - { - Conductor.forceBPM(SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)].bpm); - FlxG.log.add('CHANGED BPM!'); - } - } - } - - // Manage the camera focus, if necessary. - // controlCamera(); - - // HARDCODING FOR MILF ZOOMS! - - if (PreferencesMenu.getPref('camera-zoom')) - { - if (currentSong != null - && currentSong.song.toLowerCase() == 'milf' - && Conductor.currentBeat >= 168 - && Conductor.currentBeat < 200 - && camZooming - && FlxG.camera.zoom < 1.35) - { - FlxG.camera.zoom += 0.015 * FlxCamera.defaultZoom; - camHUD.zoom += 0.03; - } - - if (camZooming && FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom) && Conductor.currentBeat % 4 == 0) - { - FlxG.camera.zoom += 0.015 * FlxCamera.defaultZoom; - camHUD.zoom += 0.03; - } - } - - // That combo counter 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; - if (currentSong != null) - { - shouldShowComboText = (Conductor.currentBeat % 8 == 7); - var daSection = SongLoad.getSong()[Std.int(Conductor.currentBeat / 16)]; - shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection); - shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5); - - var daNextSection = SongLoad.getSong()[Std.int(Conductor.currentBeat / 16) + 1]; - var isEndOfSong = SongLoad.getSong().length < Std.int(Conductor.currentBeat / 16); - shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection)); - } - - if (shouldShowComboText) - { - var animShit:ComboCounter = new ComboCounter(-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.crochet / 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() - { - if (currentStage == null) - return; - - // TODO: Move this to a song event. - if (Conductor.currentBeat % 16 == 15 // && currentSong.song == 'Tutorial' - && 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 (!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 (!isStoryMode) - { - enemyStrumline.fadeInArrows(); - } - - this.refresh(); - } - - /** - * Function called before opening a new substate. - * @param subState The substate to open. - */ - override function openSubState(subState:FlxSubState) - { - // 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 - */ - override function closeSubState() - { - 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, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, - songLength - Conductor.songPosition); - else - DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); - #end - } - - super.closeSubState(); - } - - /** - * Prepares to start the countdown. - * Ends any running cutscenes, creates the strumlines, and starts the countdown. - */ - function startCountdown():Void - { - var result = Countdown.performCountdown(currentStageId.startsWith('school')); - if (!result) - return; - - isInCutscene = false; - camHUD.visible = true; - talking = false; - - buildStrumlines(); - } - - override function dispatchEvent(event:ScriptEvent):Void - { - // ORDER: Module, Stage, Character, Song, 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); - - // TODO: Dispatch event to song script - } - - /** - * 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); - } - - /** - * Resets the camera's zoom level and focus point. - */ - public function resetCamera():Void - { - FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04); - FlxG.camera.targetOffset.set(); - FlxG.camera.zoom = defaultCameraZoom; - FlxG.camera.focusOn(cameraFollowPoint.getPosition()); - } - - /** - * Perform necessary cleanup before leaving the PlayState. - */ - function performCleanup() - { - // Uncache the song. - if (currentChart != null) - { - } - else if (currentSong != null) - { - openfl.utils.Assets.cache.clear(Paths.inst(currentSong.song)); - openfl.utils.Assets.cache.clear(Paths.voices(currentSong.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; - } - - /** - * 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 = super.switchTo(nextState); - - if (result) - { - performCleanup(); - } - - return result; - } + /** + * STATIC VARIABLES + * Static variables should be used for information that must be persisted between states or between resets, + * such as the active song or song playlist. + */ + /** + * The currently active PlayState. + * Since there is only one PlayState in existance at a time, we can use a singleton. + */ + public static var instance:PlayState = null; + + /** + * The currently active song. Includes data about what stage should be used, what characters, + * and the notes to be played. + */ + public static var currentSong:SwagSong = null; + + public static var currentSong_NEW:Song = null; + + /** + * Whether the game is currently in Story Mode. If false, we are in Free Play Mode. + */ + public static var isStoryMode:Bool = false; + + /** + * Whether the game is currently in Practice Mode. + * If true, player will not lose gain or lose score from notes. + */ + public static var isPracticeMode:Bool = false; + + /** + * Whether the game is currently in a cutscene, and gameplay should be stopped. + */ + public static var isInCutscene:Bool = false; + + /** + * Whether the game is currently in the countdown before the song resumes. + */ + public static var isInCountdown:Bool = false; + + /** + * Gets set to true when the PlayState needs to reset (player opted to restart or died). + * Gets disabled once resetting happens. + */ + public static var needsReset:Bool = false; + + /** + * The current "Blueball Counter" to display in the pause menu. + * Resets when you beat a song or go back to the main menu. + */ + public static var deathCounter:Int = 0; + + /** + * The default camera zoom level. The camera lerps back to this after zooming in. + * Defaults to 1.05 but may be larger or smaller depending on the current stage. + */ + public static var defaultCameraZoom:Float = 1.05; + + /** + * Used to persist the position of the `cameraFollowPosition` between resets. + */ + private static var previousCameraFollowPoint:FlxObject = null; + + /** + * PUBLIC INSTANCE VARIABLES + * Public instance variables should be used for information that must be reset or dereferenced + * every time the state is reset, such as the currently active stage, but may need to be accessed externally. + */ + /** + * The currently active Stage. This is the object containing all the props. + */ + public var currentStage:Stage = null; + + 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:String = ''; + + /** + * The player's current health. + * The default maximum health is 2.0, and the default starting health is 1.0. + */ + public var health:Float = 1; + + /** + * The player's current score. + */ + 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. + * + * 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. + */ + public var cameraFollowPoint:FlxSprite = new FlxSprite(0, 0); + + /** + * 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. + */ + private var inactiveNotes:Array; + + private var songEvents:Array; + + /** + * If true, the player is allowed to pause the game. + * Disabled during the ending of a song. + */ + private var mayPauseGame:Bool = true; + + /** + * The displayed value of the player's health. + * Used to provide smooth animations based on linear interpolation of the player's health. + */ + private var healthLerp:Float = 1; + + /** + * Forcibly disables all update logic while the game moves back to the Menu state. + * This is used only when a critical error occurs and the game cannot continue. + */ + private var criticalFailure:Bool = false; + + /** + * RENDER OBJECTS + */ + /** + * The SpriteGroup containing the notes that are currently on the screen or are about to be on the screen. + */ + private var activeNotes:FlxTypedGroup = null; + + /** + * The FlxText which displays the current score. + */ + private var scoreText:FlxText; + + /** + * The bar which displays the player's health. + * Dynamically updated based on the value of `healthLerp` (which is based on `health`). + */ + public var healthBar:FlxBar; + + /** + * The background image used for the health bar. + * Emma says the image is slightly skewed so I'm leaving it as an image instead of a `createGraphic`. + */ + public var healthBarBG:FlxSprite; + + /** + * The health icon representing the player. + */ + public var iconP1:HealthIcon; + + /** + * The health icon representing the opponent. + */ + public var iconP2:HealthIcon; + + /** + * The sprite group containing active player's strumline notes. + */ + public var playerStrumline:Strumline; + + /** + * The sprite group containing opponent's strumline notes. + */ + public var enemyStrumline:Strumline; + + /** + * The camera which contains, and controls visibility of, the user interface elements. + */ + public var camHUD:FlxCamera; + + /** + * The camera which contains, and controls visibility of, the stage and characters. + */ + public var camGame:FlxCamera; + + /** + * PROPERTIES + */ + /** + * If a substate is rendering over the PlayState, it is paused and normal update logic is skipped. + * Examples include: + * - The Pause screen is open. + * - The Game Over screen is open. + * - The Chart Editor screen is open. + */ + private var isGamePaused(get, never):Bool; + + function get_isGamePaused():Bool + { + // Note: If there is a substate which requires the game to act unpaused, + // this should be changed to include something like `&& Std.isOfType()` + return this.subState != null; + } + + // TODO: Reorganize these variables (maybe there should be a separate class like Conductor just to hold them?) + public static var storyWeek:Int = 0; + public static var storyPlaylist:Array = []; + public static var storyDifficulty:Int = 1; + public static var storyDifficulty_NEW:String = "normal"; + public static var seenCutscene:Bool = false; + public static var campaignScore:Int = 0; + + private var vocals:VoicesGroup; + private var vocalsFinished:Bool = false; + + private var camZooming:Bool = false; + private var gfSpeed:Int = 1; + // private var combo:Int = 0; + private var generatedMusic:Bool = false; + private var startingSong:Bool = false; + + var dialogue:Array; + var talking:Bool = true; + var doof:DialogueBox; + 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 + + override public function create() + { + super.create(); + + if (currentSong == null && currentSong_NEW == null) + { + criticalFailure = true; + + lime.app.Application.current.window.alert("There was a critical error while accessing the selected song. Click OK to return to the main menu.", + "Error loading PlayState"); + FlxG.switchState(new MainMenuState()); + return; + } + + instance = this; + + if (currentSong_NEW != null) + { + // TODO: Do this in the loading state. + currentSong_NEW.cacheCharts(true); + } + + // 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; + + // Reduce physics accuracy (who cares!!!) to improve animation quality. + FlxG.fixedTimestep = false; + + // This state receives update() even when a substate is active. + this.persistentUpdate = true; + // This state receives draw calls even when a substate is active. + this.persistentDraw = true; + + // Stop any pre-existing music. + if (FlxG.sound.music != null) + FlxG.sound.music.stop(); + + // Prepare the current song to be played. + if (currentChart != null) + { + currentChart.cacheInst(); + currentChart.cacheVocals(); + } + else + { + FlxG.sound.cache(Paths.inst(currentSong.song)); + FlxG.sound.cache(Paths.voices(currentSong.song)); + } + + // Initialize stage stuff. + initCameras(); + + if (currentSong == null && currentSong_NEW == null) + { + currentSong = SongLoad.loadFromJson('tutorial'); + } + + if (currentSong_NEW != null) + { + Conductor.mapTimeChanges(currentChart.timeChanges); + // Conductor.bpm = currentChart.getStartingBPM(); + + // TODO: Support for dialog. + } + else + { + Conductor.mapBPMChanges(currentSong); + // Conductor.bpm = currentSong.bpm; + + switch (currentSong.song.toLowerCase()) + { + case 'senpai': + dialogue = CoolUtil.coolTextFile(Paths.txt('songs/senpai/senpaiDialogue')); + case 'roses': + dialogue = CoolUtil.coolTextFile(Paths.txt('songs/roses/rosesDialogue')); + case 'thorns': + dialogue = CoolUtil.coolTextFile(Paths.txt('songs/thorns/thornsDialogue')); + } + } + + Conductor.update(-5000); + + if (dialogue != null) + { + doof = new DialogueBox(false, dialogue); + doof.scrollFactor.set(); + doof.finishThing = startCountdown; + doof.cameras = [camHUD]; + } + + // 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); + + initStage(); + initCharacters(); + #if discord_rpc + initDiscord(); + #end + + // Configure camera follow point. + if (previousCameraFollowPoint != null) + { + cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y); + previousCameraFollowPoint = null; + } + add(cameraFollowPoint); + + comboPopUps = new PopUpStuff(); + comboPopUps.cameras = [camHUD]; + add(comboPopUps); + + grpNoteSplashes = new FlxTypedGroup(); + + var noteSplash:NoteSplash = new NoteSplash(100, 100, 0); + grpNoteSplashes.add(noteSplash); + noteSplash.alpha = 0.1; + + add(grpNoteSplashes); + + if (currentSong_NEW != null) + { + generateSong_NEW(); + } + else + { + 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); + + // 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 (SONG.song == 'South') + // FlxG.camera.alpha = 0.7; + // UI_camera.zoom = 1; + + // cameras = [FlxG.cameras.list[1]]; + startingSong = true; + + if (isStoryMode && !seenCutscene) + { + seenCutscene = true; + + switch (currentSong.song.toLowerCase()) + { + case "winter-horrorland": + VanillaCutscenes.playHorrorStartCutscene(); + case 'senpai' | 'roses' | 'thorns': + schoolIntro(doof); // doof is assumed to be non-null, lol! + case 'ugh': + VanillaCutscenes.playUghCutscene(); + case 'stress': + VanillaCutscenes.playStressCutscene(); + case 'guns': + VanillaCutscenes.playGunsCutscene(); + default: + // VanillaCutscenes will call startCountdown later. + // TODO: Alternatively: make a song script that allows startCountdown to be called, + // then cancels the countdown, hides the strumline, plays the cutscene, + // then calls Countdown.performCountdown() + startCountdown(); + } + } + else + { + startCountdown(); + } + + #if debug + this.rightWatermarkText.text = Constants.VERSION; + #end + } + + function get_currentChart():SongDifficulty + { + if (currentSong_NEW == null || storyDifficulty_NEW == null) + return null; + return currentSong_NEW.getDifficulty(storyDifficulty_NEW); + } + + /** + * Initializes the game and HUD cameras. + */ + function initCameras() + { + // Configure the default camera zoom level. + defaultCameraZoom = FlxCamera.defaultZoom * 1.05; + + camGame = new SwagCamera(); + camHUD = new FlxCamera(); + camHUD.bgColor.alpha = 0; + + FlxG.cameras.reset(camGame); + FlxG.cameras.add(camHUD, false); + } + + function initStage() + { + if (currentSong_NEW != null) + { + initStage_NEW(); + return; + } + + // TODO: Move stageId to the song file. + switch (currentSong.song.toLowerCase()) + { + case 'spookeez' | 'monster' | 'south': + currentStageId = "spookyMansion"; + case 'pico' | 'blammed' | 'philly': + currentStageId = 'phillyTrain'; + case "milf" | 'satin-panties' | 'high': + currentStageId = 'limoRide'; + case "cocoa" | 'eggnog': + currentStageId = 'mallXmas'; + case 'winter-horrorland': + currentStageId = 'mallEvil'; + case 'senpai' | 'roses': + currentStageId = 'school'; + case "darnell" | "lit-up" | "2hot": + currentStageId = 'phillyStreets'; + // currentStageId = 'pyro'; + case "blazin": + currentStageId = 'phillyBlazin'; + // currentStageId = 'pyro'; + case 'pyro': + currentStageId = 'pyro'; + case 'thorns': + currentStageId = 'schoolEvil'; + case 'guns' | 'stress' | 'ugh': + currentStageId = 'tankmanBattlefield'; + default: + currentStageId = "mainStage"; + } + // Loads the relevant stage based on its ID. + loadStage(currentStageId); + } + + function initStage_NEW() + { + if (currentChart == null) + { + trace('Song difficulty could not be loaded.'); + } + + if (currentChart.stage != null && currentChart.stage != '') + { + currentStageId = currentChart.stage; + } + else + { + currentStageId = SongValidator.DEFAULT_STAGE; + } + + loadStage(currentStageId); + } + + function initCharacters() + { + if (currentSong_NEW != null) + { + initCharacters_NEW(); + return; + } + + iconP1 = new HealthIcon(currentSong.player1, 0); + iconP1.y = healthBar.y - (iconP1.height / 2); + add(iconP1); + + iconP2 = new HealthIcon(currentSong.player2, 1); + iconP2.y = healthBar.y - (iconP2.height / 2); + add(iconP2); + + // + // GIRLFRIEND + // + + // TODO: Tie the GF version to the song data, not the stage ID or the current player. + var gfVersion:String = 'gf'; + + switch (currentStageId) + { + case 'pyro' | 'phillyStreets': + gfVersion = 'nene'; + case 'blazin': + gfVersion = ''; + case 'limoRide': + gfVersion = 'gf-car'; + case 'mallXmas' | 'mallEvil': + gfVersion = 'gf-christmas'; + case 'school' | 'schoolEvil': + gfVersion = 'gf-pixel'; + case 'tankmanBattlefield': + gfVersion = 'gf-tankmen'; + } + + if (currentSong.player1 == "pico") + gfVersion = "nene"; + + if (currentSong.song.toLowerCase() == 'stress') + gfVersion = 'pico-speaker'; + + if (currentSong.song.toLowerCase() == 'tutorial') + gfVersion = ''; + + // + // GIRLFRIEND + // + var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(gfVersion); + + if (girlfriend != null) + { + girlfriend.characterType = CharacterType.GF; + girlfriend.scrollFactor.set(0.95, 0.95); + if (gfVersion == 'pico-speaker') + { + girlfriend.x -= 50; + girlfriend.y -= 200; + } + } + else if (gfVersion != '') + { + trace('WARNING: Could not load girlfriend character with ID ${gfVersion}, skipping...'); + } + + // + // DAD + // + var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player2); + + if (dad != null) + { + dad.characterType = CharacterType.DAD; + } + + switch (currentSong.player2) + { + case 'gf': + if (isStoryMode) + { + cameraFollowPoint.x += 600; + tweenCamIn(); + } + } + + // + // BOYFRIEND + // + var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player1); + + if (boyfriend != null) + { + boyfriend.characterType = CharacterType.BF; + } + + if (currentStage != null) + { + // We're using Eric's stage handler. + // Characters get added to the stage, not the main scene. + if (girlfriend != null) + { + currentStage.addCharacter(girlfriend, GF); + } + + if (boyfriend != null) + { + currentStage.addCharacter(boyfriend, BF); + } + + if (dad != null) + { + currentStage.addCharacter(dad, DAD); + // Camera starts at dad. + cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y); + } + + // Redo z-indexes. + currentStage.refresh(); + } + } + + function initCharacters_NEW() + { + if (currentSong_NEW == 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 = currentChart.getPlayableChars(); + var currentPlayer = 'bf'; + + if (playableChars.length == 0) + { + trace('WARNING: No playable characters found for this song.'); + } + else if (playableChars.indexOf(currentPlayer) == -1) + { + currentPlayer = playableChars[0]; + } + + var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayer); + + // + // 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; + } + + // TODO: Cut out this code/make it generic. + switch (currentCharData.opponent) + { + case 'gf': + if (isStoryMode) + { + cameraFollowPoint.x += 600; + tweenCamIn(); + } + } + + // + // OPPONENT HEALTH ICON + // + iconP2 = new HealthIcon(currentCharData.opponent, 1); + iconP2.y = healthBar.y - (iconP2.height / 2); + add(iconP2); + + // + // BOYFRIEND + // + var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayer); + + if (boyfriend != null) + { + boyfriend.characterType = CharacterType.BF; + } + + // + // PLAYER HEALTH ICON + // + iconP1 = new HealthIcon(currentPlayer, 0); + iconP1.y = healthBar.y - (iconP1.height / 2); + 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 (boyfriend != null) + { + currentStage.addCharacter(boyfriend, BF); + } + + if (dad != null) + { + currentStage.addCharacter(dad, DAD); + // Camera starts at dad. + cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y); + } + + // 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() + { + // 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; + } + + super.debug_refreshModules(); + } + + /** + * Pauses music and vocals easily. + */ + public function pauseMusic() + { + 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) + { + 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. + defaultCameraZoom = currentStage.camZoom; + + // Add the stage to the scene. + this.add(currentStage); + } + } + + 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, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); + #end + } + + function schoolIntro(?dialogueBox:DialogueBox):Void + { + var black:FlxSprite = new FlxSprite(-100, -100).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK); + black.scrollFactor.set(); + add(black); + + var red:FlxSprite = new FlxSprite(-100, -100).makeGraphic(FlxG.width * 2, FlxG.height * 2, 0xFFff1b31); + red.scrollFactor.set(); + + var senpaiEvil:FlxSprite = new FlxSprite(); + senpaiEvil.frames = Paths.getSparrowAtlas('weeb/senpaiCrazy'); + senpaiEvil.animation.addByPrefix('idle', 'Senpai Pre Explosion', 24, false); + senpaiEvil.setGraphicSize(Std.int(senpaiEvil.width * Constants.PIXEL_ART_SCALE)); + senpaiEvil.scrollFactor.set(); + senpaiEvil.updateHitbox(); + senpaiEvil.screenCenter(); + senpaiEvil.x += senpaiEvil.width / 5; + + if (currentSong.song.toLowerCase() == 'roses' || currentSong.song.toLowerCase() == 'thorns') + { + remove(black); + + if (currentSong.song.toLowerCase() == 'thorns') + { + add(red); + camHUD.visible = false; + } + else + FlxG.sound.play(Paths.sound('ANGRY')); + // moved senpai angry noise in here to clean up cutscene switch case lol + } + + new FlxTimer().start(0.3, function(tmr:FlxTimer) + { + black.alpha -= 0.15; + + if (black.alpha > 0) + tmr.reset(0.3); + else + { + if (dialogueBox != null) + { + isInCutscene = true; + + if (currentSong.song.toLowerCase() == 'thorns') + { + add(senpaiEvil); + senpaiEvil.alpha = 0; + new FlxTimer().start(0.3, function(swagTimer:FlxTimer) + { + senpaiEvil.alpha += 0.15; + if (senpaiEvil.alpha < 1) + swagTimer.reset(); + else + { + senpaiEvil.animation.play('idle'); + FlxG.sound.play(Paths.sound('Senpai_Dies'), 1, false, null, true, function() + { + remove(senpaiEvil); + remove(red); + FlxG.camera.fade(FlxColor.WHITE, 0.01, true, function() + { + add(dialogueBox); + camHUD.visible = true; + }, true); + }); + new FlxTimer().start(3.2, function(deadTime:FlxTimer) + { + FlxG.camera.fade(FlxColor.WHITE, 1.6, false); + }); + } + }); + } + else + add(dialogueBox); + } + else + startCountdown(); + + remove(black); + } + }); + } + + function startSong():Void + { + dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START)); + + startingSong = false; + + previousFrameTime = FlxG.game.ticks; + + if (!isGamePaused) + { + // if (FlxG.sound.music != null) + // FlxG.sound.music.play(true); + // else + if (currentChart != null) + { + currentChart.playInst(1.0, false); + } + else + { + FlxG.sound.playMusic(Paths.inst(currentSong.song), 1, false); + } + } + + FlxG.sound.music.onComplete = endSong; + trace('Playing 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, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength); + #end + } + + private function generateSong():Void + { + // FlxG.log.add(ChartParser.parse()); + + Conductor.forceBPM(currentSong.bpm); + + currentSong.song = currentSong.song; + + if (currentSong.needsVoices) + vocals = VoicesGroup.build(currentSong.song, currentSong.voiceList); + else + vocals = VoicesGroup.build(currentSong.song, null); + + vocals.members[0].onComplete = function() + { + vocalsFinished = true; + }; + + trace(vocals); + + activeNotes = new FlxTypedGroup(); + activeNotes.zIndex = 1000; + add(activeNotes); + + regenNoteData(); + + generatedMusic = true; + } + + private function generateSong_NEW():Void + { + if (currentChart == null) + { + trace('Song difficulty could not be loaded.'); + } + + Conductor.forceBPM(currentChart.getStartingBPM()); + + // TODO: Fix grouped vocals + vocals = currentChart.buildVocals(); + vocals.members[0].onComplete = function() + { + vocalsFinished = true; + } + + // Create the rendered note group. + activeNotes = new FlxTypedGroup(); + activeNotes.zIndex = 1000; + add(activeNotes); + + regenNoteData_NEW(); + + generatedMusic = true; + } + + function regenNoteData():Void + { + // resets combo, should prob put somewhere else! + Highscore.tallies.combo = 0; + Highscore.tallies = new Tallies(); + // make unspawn notes shit def empty + inactiveNotes = []; + + 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; + + // NEW SHIT + noteData = SongLoad.getSong(); + + for (section in noteData) + { + for (songNotes in section.sectionNotes) + { + var daStrumTime:Float = songNotes.strumTime; + // TODO: Replace 4 with strumlineSize + var daNoteData:Int = Std.int(songNotes.noteData % 4); + var gottaHitNote:Bool = section.mustHitSection; + + if (songNotes.highStakes) // noteData > 3 + gottaHitNote = !section.mustHitSection; + + var oldNote:Note; + if (inactiveNotes.length > 0) + oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)]; + else + oldNote = null; + + var strumlineStyle:StrumlineStyle = NORMAL; + + // TODO: Put this in the chart or something? + switch (currentStageId) + { + case 'school': + strumlineStyle = PIXEL; + case 'schoolEvil': + strumlineStyle = PIXEL; + } + + var swagNote:Note = new Note(daStrumTime, daNoteData, oldNote, false, strumlineStyle); + // swagNote.data = songNotes; + swagNote.data.sustainLength = songNotes.sustainLength; + swagNote.data.noteKind = songNotes.noteKind; + swagNote.scrollFactor.set(0, 0); + + var susLength:Float = swagNote.data.sustainLength; + + susLength = susLength / Conductor.stepCrochet; + inactiveNotes.push(swagNote); + + for (susNote in 0...Math.round(susLength)) + { + oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)]; + + var sustainNote:Note = new Note(daStrumTime + (Conductor.stepCrochet * susNote) + Conductor.stepCrochet, daNoteData, oldNote, true, strumlineStyle); + sustainNote.data.noteKind = songNotes.noteKind; + sustainNote.scrollFactor.set(); + inactiveNotes.push(sustainNote); + + sustainNote.mustPress = gottaHitNote; + + if (sustainNote.mustPress) + sustainNote.x += FlxG.width / 2; // general offset + } + + // TODO: Replace 4 with strumlineSize + swagNote.mustPress = gottaHitNote; + + if (swagNote.mustPress) + { + if (playerStrumline != null) + { + swagNote.x = playerStrumline.getArrow(swagNote.data.noteData).x; + } + else + { + swagNote.x += FlxG.width / 2; // general offset + } + } + else + { + if (enemyStrumline != null) + { + swagNote.x = enemyStrumline.getArrow(swagNote.data.noteData).x; + } + else + { + // swagNote.x += FlxG.width / 2; // general offset + } + } + } + } + + inactiveNotes.sort(function(a:Note, b:Note):Int + { + return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b); + }); + } + + function regenNoteData_NEW():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) + { + if (playerStrumline != null) + { + // Align with the strumline arrow. + newNote.x = playerStrumline.getArrow(songNote.getDirection()).x; + } + else + { + // Assume strumline position. + newNote.x += FlxG.width / 2; + } + } + else + { + if (enemyStrumline != null) + { + newNote.x = enemyStrumline.getArrow(songNote.getDirection()).x; + } + else + { + // newNote.x += 0; + } + } + + inactiveNotes.push(newNote); + + oldNote = newNote; + + // Generate X sustain notes. + var sustainSections = Math.round(songNote.length / Conductor.stepCrochet); + for (noteIndex in 0...sustainSections) + { + var noteTimeOffset:Float = Conductor.stepCrochet + (Conductor.stepCrochet * 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) + { + if (playerStrumline != null) + { + // Align with the strumline arrow. + sustainNote.x = playerStrumline.getArrow(songNote.getDirection()).x; + } + else + { + // Assume strumline position. + sustainNote.x += FlxG.width / 2; + } + } + else + { + if (enemyStrumline != null) + { + sustainNote.x = enemyStrumline.getArrow(songNote.getDirection()).x; + } + else + { + // newNote.x += 0; + } + } + + inactiveNotes.push(sustainNote); + + oldNote = sustainNote; + } + } + + // Sorting is an expensive operation. + // Assume it was done in the chart file. + /** + inactiveNotes.sort(function(a:Note, b:Note):Int + { + return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b); + }); + **/ + } + + function tweenCamIn():Void + { + FlxTween.tween(FlxG.camera, {zoom: 1.3 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut}); + } + + #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(FlxG.sound.music.time + Conductor.offset); + + if (vocalsFinished) + return; + + vocals.time = FlxG.sound.music.time; + vocals.play(); + } + + override public function update(elapsed:Float) + { + super.update(elapsed); + + if (criticalFailure) + return; + + if (FlxG.keys.justPressed.U) + { + // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!! + persistentUpdate = false; + openSubState(new StageOffsetSubstate()); + } + + updateHealthBar(); + updateScoreText(); + + if (needsReset) + { + dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY)); + + resetCamera(); + + persistentUpdate = true; + persistentDraw = true; + + startingSong = true; + + FlxG.sound.music.pause(); + vocals.pause(); + + FlxG.sound.music.time = 0; + + currentStage.resetStage(); + + // Delete all notes and reset the arrays. + if (currentChart != null) + { + regenNoteData_NEW(); + } + else + { + regenNoteData(); + } + + health = 1; + songScore = 0; + Highscore.tallies.combo = 0; + Countdown.performCountdown(currentStageId.startsWith('school')); + + needsReset = false; + } + + #if !debug + perfectMode = false; + #else + if (FlxG.keys.justPressed.H) + camHUD.visible = !camHUD.visible; + #end + + // do this BEFORE super.update() so songPosition is accurate + if (startingSong) + { + if (isInCountdown) + { + Conductor.songPosition += elapsed * 1000; + if (Conductor.songPosition >= 0) + startSong(); + } + } + else + { + if (Paths.SOUND_EXT == 'mp3') + Conductor.offset = -13; // DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM! + + Conductor.update(FlxG.sound.music.time + Conductor.offset); + + 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; + } + } + } + + var androidPause:Bool = false; + + #if android + androidPause = FlxG.android.justPressed.BACK; + #end + + if ((controls.PAUSE || androidPause) && isInCountdown && mayPauseGame) + { + var event = new PauseScriptEvent(FlxG.random.bool(1 / 1000)); + + dispatchEvent(event); + + if (!event.eventCanceled) + { + // Pause updates while the substate is open, preventing the game state from advancing. + persistentUpdate = false; + // Enable drawing while the substate is open, allowing the game state to be shown behind the pause menu. + persistentDraw = true; + + // There is a 1/1000 change to use a special pause menu. + // This prevents the player from resuming, but that's the point. + // It's a reference to Gitaroo Man, which doesn't let you pause the game. + if (event.gitaroo) + { + FlxG.switchState(new GitarooPause()); + } + else + { + var boyfriendPos = currentStage.getBoyfriend().getScreenPosition(); + var pauseSubState = new PauseSubState(boyfriendPos.x, boyfriendPos.y); + openSubState(pauseSubState); + pauseSubState.camera = camHUD; + boyfriendPos.put(); + } + + #if discord_rpc + DiscordClient.changePresence(detailsPausedText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); + #end + } + } + + #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) + { + FlxG.switchState(new ChartingState()); + + #if discord_rpc + DiscordClient.changePresence("Chart Editor", null, null, true); + #end + } + + // 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 (health > 2.0) + health = 2.0; + if (health < 0.0) + health = 0.0; + + if (camZooming && subState == null) + { + FlxG.camera.zoom = FlxMath.lerp(defaultCameraZoom, FlxG.camera.zoom, 0.95); + camHUD.zoom = FlxMath.lerp(1 * FlxCamera.defaultZoom, camHUD.zoom, 0.95); + } + + FlxG.watch.addQuick("beatShit", Conductor.currentBeat); + FlxG.watch.addQuick("stepShit", Conductor.currentStep); + if (currentStage != null) + { + FlxG.watch.addQuick("bfAnim", currentStage.getBoyfriend().getCurrentAnimation()); + } + FlxG.watch.addQuick("songPos", Conductor.songPosition); + + if (currentSong != null && currentSong.song == 'Fresh') + { + switch (Conductor.currentBeat) + { + case 16: + camZooming = true; + gfSpeed = 2; + case 48: + gfSpeed = 1; + case 80: + gfSpeed = 2; + case 112: + gfSpeed = 1; + } + } + + if (!isInCutscene && !_exiting) + { + // RESET = Quick Game Over Screen + if (controls.RESET) + { + health = 0; + trace("RESET = True"); + } + + #if CAN_CHEAT // brandon's a pussy + if (controls.CHEAT) + { + health += 1; + trace("User is cheating!"); + } + #end + + if (health <= 0 && !isPracticeMode) + { + vocals.pause(); + FlxG.sound.music.pause(); + + deathCounter += 1; + + dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER)); + + // Disable updates, preventing animations in the background from playing. + persistentUpdate = false; + #if debug + if (FlxG.keys.pressed.THREE) + { + // TODO: Change the key or delete this? + // In debug builds, pressing 3 to kill the player makes the background transparent. + persistentDraw = true; + } + else + { + #end + persistentDraw = false; + #if debug + } + #end + + var gameOverSubstate = new GameOverSubstate(); + openSubState(gameOverSubstate); + + #if discord_rpc + // Game Over doesn't get his own variable because it's only used here + DiscordClient.changePresence("Game Over - " + detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); + #end + } + } + + while (inactiveNotes[0] != null && inactiveNotes[0].data.strumTime - Conductor.songPosition < 1800 / SongLoad.getSpeed()) + { + var dunceNote:Note = inactiveNotes[0]; + + if (dunceNote.mustPress && !dunceNote.isSustainNote) + Highscore.tallies.totalNotes++; + + activeNotes.add(dunceNote); + + inactiveNotes.shift(); + } + + 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 = playerStrumline.y + Note.swagWidth / 2; + + if (daNote.followsTime) + daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(SongLoad.getSpeed(), 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) + { + if (currentSong != null && currentSong.song != 'Tutorial') + camZooming = true; + + 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; + } + else + { + // Volume of DAD. + if (currentSong != null && currentSong.needsVoices) + vocals.volume = 1; + } + } + + // WIP interpolation shit? Need to fix the pause issue + // daNote.y = (strumLine.y - (songTime - daNote.strumTime) * (0.45 * SONG.speed[SongLoad.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); + } + }); + } + + if (songEvents != null && songEvents.length > 0) + { + var songEventsToActivate:Array = SongEventParser.queryEvents(songEvents, Conductor.songPosition); + + if (songEventsToActivate.length > 0) + { + trace('Found ${songEventsToActivate.length} event(s) to activate.'); + for (event in songEventsToActivate) + { + SongEventParser.handleEvent(event); + } + } + } + + if (!isInCutscene) + keyShit(true); + } + + 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 = 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 sec + */ + function changeSection(sec:Int):Void + { + FlxG.sound.music.pause(); + + var daBPM:Float = currentSong.bpm; + var daPos:Float = 0; + for (i in 0...(Std.int(Conductor.currentStep / 16 + sec))) + { + var section = SongLoad.getSong()[i]; + if (section == null) + continue; + if (section.changeBPM) + { + daBPM = SongLoad.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)); + + seenCutscene = false; + deathCounter = 0; + mayPauseGame = false; + FlxG.sound.music.volume = 0; + vocals.volume = 0; + if (currentSong != null && currentSong.validScore) + { + // crackhead double thingie, sets whether was new highscore, AND saves the song! + Highscore.tallies.isNewHighscore = Highscore.saveScore(currentSong.song, songScore, storyDifficulty); + + Highscore.saveCompletion(currentSong.song, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, storyDifficulty); + } + + if (isStoryMode) + { + campaignScore += songScore; + + storyPlaylist.remove(storyPlaylist[0]); + + if (storyPlaylist.length <= 0) + { + FlxG.sound.playMusic(Paths.music('freakyMenu')); + + transIn = FlxTransitionableState.defaultTransIn; + transOut = FlxTransitionableState.defaultTransOut; + + switch (storyWeek) + { + case 7: + FlxG.switchState(new VideoState()); + default: + FlxG.switchState(new StoryMenuState()); + } + + // if () + StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true; + + if (currentSong.validScore) + { + NGio.unlockMedal(60961); + Highscore.saveWeekScore(storyWeek, campaignScore, storyDifficulty); + } + + FlxG.save.data.weekUnlocked = StoryMenuState.weekUnlocked; + FlxG.save.flush(); + } + else + { + var difficulty:String = ""; + + if (storyDifficulty == 0) + difficulty = '-easy'; + + if (storyDifficulty == 2) + difficulty = '-hard'; + + trace('LOADING NEXT SONG'); + trace(storyPlaylist[0].toLowerCase() + difficulty); + + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + + FlxG.sound.music.stop(); + vocals.stop(); + + if (currentSong.song.toLowerCase() == 'eggnog') + { + var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom, + -FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK); + blackShit.scrollFactor.set(); + add(blackShit); + camHUD.visible = false; + isInCutscene = true; + + FlxG.sound.play(Paths.sound('Lights_Shut_off'), function() + { + // no camFollow so it centers on horror tree + currentSong = SongLoad.loadFromJson(storyPlaylist[0].toLowerCase() + difficulty, storyPlaylist[0]); + LoadingState.loadAndSwitchState(new PlayState()); + }); + } + else + { + previousCameraFollowPoint = cameraFollowPoint; + + currentSong = SongLoad.loadFromJson(storyPlaylist[0].toLowerCase() + difficulty, storyPlaylist[0]); + LoadingState.loadAndSwitchState(new PlayState()); + } + } + } + else + { + trace('WENT TO RESULTS SCREEN!'); + // unloadAssets(); + + camZooming = false; + + FlxG.camera.follow(PlayState.instance.currentStage.getGirlfriend(), null, 0.05); + FlxG.camera.targetOffset.y -= 350; + FlxG.camera.targetOffset.x += 20; + + FlxTween.tween(camHUD, {alpha: 0}, 0.6); + + new FlxTimer().start(0.8, _ -> + { + currentStage.getGirlfriend().animation.play("cheer"); + + FlxTween.tween(FlxG.camera, {zoom: 1200}, 1.1, { + ease: FlxEase.expoIn, + onComplete: _ -> + { + persistentUpdate = false; + vocals.stop(); + camHUD.alpha = 1; + var res:ResultState = new ResultState(); + res.camera = camHUD; + openSubState(res); + } + }); + }); + // FlxG.switchState(new FreeplayState()); + } + } + + // gives score and pops up rating + private function popUpScore(strumtime:Float, daNote:Note):Void + { + var noteDiff:Float = Math.abs(strumtime - Conductor.songPosition); + // boyfriend.playAnimation('hey'); + vocals.volume = 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; + comboPopUps.displayRating(daRating); + if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0) + comboPopUps.displayCombo(Highscore.tallies.combo); + } + + /* + function controlCamera() + { + if (currentStage == null) + return; + + switch (cameraFocusCharacter) + { + default: // null = No change + break; + case 0: // Boyfriend + var isFocusedOnBF = cameraFollowPoint.x == currentStage.getBoyfriend().cameraFocusPoint.x; + if (!isFocusedOnBF) + { + // Focus the camera on the player. + cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y); + } + case 1: // Dad + var isFocusedOnDad = cameraFollowPoint.x == currentStage.getDad().cameraFocusPoint.x; + if (!isFocusedOnDad) + { + cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y); + } + case 2: // Girlfriend + var isFocusedOnGF = cameraFollowPoint.x == currentStage.getGirlfriend().cameraFocusPoint.x; + if (!isFocusedOnGF) + { + cameraFollowPoint.setPosition(currentStage.getGirlfriend().cameraFocusPoint.x, currentStage.getGirlfriend().cameraFocusPoint.y); + } + } + + /* + if (cameraRightSide && !isFocusedOnBF) + { + // Focus the camera on the player. + cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y); + + // TODO: Un-hardcode this. + if (currentSong.song.toLowerCase() == 'tutorial') + FlxTween.tween(FlxG.camera, {zoom: 1 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut}); + } + else if (!cameraRightSide && !isFocusedOnDad) + { + // Focus the camera on the opponent. + cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y); + + // TODO: Un-hardcode this stuff. + if (currentStage.getDad().characterId == 'mom') + { + vocals.volume = 1; + } + + if (currentSong.song.toLowerCase() == 'tutorial') + tweenCamIn(); + } + */ + // } + + public function keyShit(test:Bool):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 + ]; + // 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); + + PlayState.instance.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'); + } + } + } + + /** + * 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; + + if (event.playSound) + { + vocals.volume = 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; + vocals.volume = 0; + + if (Highscore.tallies.combo != 0) + { + Highscore.tallies.combo = comboPopUps.displayCombo(0); + } + + 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.volume = 1; + + if (!note.isSustainNote) + { + note.kill(); + activeNotes.remove(note, true); + note.destroy(); + } + } + } + + override function stepHit():Bool + { + if (SongLoad.songData == null) + return false; + + // 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)) > 20 + || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 20) + { + 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); + } + + // Moving this code into the `beatHit` function allows for scripts and modules to control the camera better. + if (currentSong != null) + { + if (generatedMusic && SongLoad.getSong()[Std.int(Conductor.currentStep / 16)] != null) + { + // cameraRightSide = SongLoad.getSong()[Std.int(Conductor.currentStep / 16)].mustHitSection; + } + + if (SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)] != null) + { + if (SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)].changeBPM) + { + Conductor.forceBPM(SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)].bpm); + FlxG.log.add('CHANGED BPM!'); + } + } + } + + // Manage the camera focus, if necessary. + // controlCamera(); + + // HARDCODING FOR MILF ZOOMS! + + if (PreferencesMenu.getPref('camera-zoom')) + { + if (currentSong != null + && currentSong.song.toLowerCase() == 'milf' + && Conductor.currentBeat >= 168 + && Conductor.currentBeat < 200 + && camZooming + && FlxG.camera.zoom < 1.35) + { + FlxG.camera.zoom += 0.015 * FlxCamera.defaultZoom; + camHUD.zoom += 0.03; + } + + if (camZooming && FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom) && Conductor.currentBeat % 4 == 0) + { + FlxG.camera.zoom += 0.015 * FlxCamera.defaultZoom; + camHUD.zoom += 0.03; + } + } + + // That combo counter 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; + if (currentSong != null) + { + shouldShowComboText = (Conductor.currentBeat % 8 == 7); + var daSection = SongLoad.getSong()[Std.int(Conductor.currentBeat / 16)]; + shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection); + shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5); + + var daNextSection = SongLoad.getSong()[Std.int(Conductor.currentBeat / 16) + 1]; + var isEndOfSong = SongLoad.getSong().length < Std.int(Conductor.currentBeat / 16); + shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection)); + } + + if (shouldShowComboText) + { + var animShit:ComboCounter = new ComboCounter(-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.crochet / 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() + { + if (currentStage == null) + return; + + // TODO: Move this to a song event. + if (Conductor.currentBeat % 16 == 15 // && currentSong.song == 'Tutorial' + && 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 (!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 (!isStoryMode) + { + enemyStrumline.fadeInArrows(); + } + + this.refresh(); + } + + /** + * Function called before opening a new substate. + * @param subState The substate to open. + */ + override function openSubState(subState:FlxSubState) + { + // 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 + */ + override function closeSubState() + { + 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, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength - Conductor.songPosition); + else + DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); + #end + } + + super.closeSubState(); + } + + /** + * Prepares to start the countdown. + * Ends any running cutscenes, creates the strumlines, and starts the countdown. + */ + function startCountdown():Void + { + var result = Countdown.performCountdown(currentStageId.startsWith('school')); + if (!result) + return; + + isInCutscene = false; + camHUD.visible = true; + talking = false; + + buildStrumlines(); + } + + override function dispatchEvent(event:ScriptEvent):Void + { + // ORDER: Module, Stage, Character, Song, 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); + + // TODO: Dispatch event to song script + } + + /** + * 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); + } + + /** + * Resets the camera's zoom level and focus point. + */ + public function resetCamera():Void + { + FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04); + FlxG.camera.targetOffset.set(); + FlxG.camera.zoom = defaultCameraZoom; + FlxG.camera.focusOn(cameraFollowPoint.getPosition()); + } + + /** + * Perform necessary cleanup before leaving the PlayState. + */ + function performCleanup() + { + // Uncache the song. + if (currentChart != null) {} + else if (currentSong != null) + { + openfl.utils.Assets.cache.clear(Paths.inst(currentSong.song)); + openfl.utils.Assets.cache.clear(Paths.voices(currentSong.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; + } + + /** + * 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 = super.switchTo(nextState); + + if (result) + { + performCleanup(); + } + + return result; + } } diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx index 2eaf4f944..7dcbf9cf6 100644 --- a/source/funkin/play/character/CharacterData.hx +++ b/source/funkin/play/character/CharacterData.hx @@ -18,589 +18,589 @@ import openfl.utils.Assets; class CharacterDataParser { - /** - * The current version string for the stage data format. - * Handle breaking changes by incrementing this value - * and adding migration to the `migrateStageData()` function. - */ - public static final CHARACTER_DATA_VERSION:String = "1.0.0"; + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final CHARACTER_DATA_VERSION:String = "1.0.0"; - /** - * The current version rule check for the stage data format. - */ - public static final CHARACTER_DATA_VERSION_RULE:String = "1.0.x"; + /** + * The current version rule check for the stage data format. + */ + public static final CHARACTER_DATA_VERSION_RULE:String = "1.0.x"; - static final characterCache:Map = new Map(); - static final characterScriptedClass:Map = new Map(); + static final characterCache:Map = new Map(); + static final characterScriptedClass:Map = new Map(); - static final DEFAULT_CHAR_ID:String = 'UNKNOWN'; + static final DEFAULT_CHAR_ID:String = 'UNKNOWN'; - /** - * Parses and preloads the game's stage data and scripts when the game starts. - * - * If you want to force stages to be reloaded, you can just call this function again. - */ - public static function loadCharacterCache():Void - { - // Clear any stages that are cached if there were any. - clearCharacterCache(); - trace("[CHARDATA] Loading character cache..."); + /** + * Parses and preloads the game's stage data and scripts when the game starts. + * + * If you want to force stages to be reloaded, you can just call this function again. + */ + public static function loadCharacterCache():Void + { + // Clear any stages that are cached if there were any. + clearCharacterCache(); + trace("Loading character cache..."); - // - // UNSCRIPTED CHARACTERS - // - var charIdList:Array = DataAssets.listDataFilesInPath('characters/'); - var unscriptedCharIds:Array = charIdList.filter(function(charId:String):Bool - { - return !characterCache.exists(charId); - }); - trace(' Fetching data for ${unscriptedCharIds.length} characters...'); - for (charId in unscriptedCharIds) - { - try - { - var charData:CharacterData = parseCharacterData(charId); - if (charData != null) - { - trace(' Loaded character data: ${charId}'); - characterCache.set(charId, charData); - } - } - catch (e) - { - // Assume error was already logged. - continue; - } - } + // + // UNSCRIPTED CHARACTERS + // + var charIdList:Array = DataAssets.listDataFilesInPath('characters/'); + var unscriptedCharIds:Array = charIdList.filter(function(charId:String):Bool + { + return !characterCache.exists(charId); + }); + trace(' Fetching data for ${unscriptedCharIds.length} characters...'); + for (charId in unscriptedCharIds) + { + try + { + var charData:CharacterData = parseCharacterData(charId); + if (charData != null) + { + trace(' Loaded character data: ${charId}'); + characterCache.set(charId, charData); + } + } + catch (e) + { + // Assume error was already logged. + continue; + } + } - // - // SCRIPTED CHARACTERS - // + // + // SCRIPTED CHARACTERS + // - // Fuck I wish scripted classes supported static functions. + // Fuck I wish scripted classes supported static functions. - var scriptedCharClassNames1:Array = ScriptedSparrowCharacter.listScriptClasses(); - if (scriptedCharClassNames1.length > 0) - { - trace(' Instantiating ${scriptedCharClassNames1.length} (Sparrow) scripted characters...'); - for (charCls in scriptedCharClassNames1) - { - var character = ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID); - characterScriptedClass.set(character.characterId, charCls); - } - } + var scriptedCharClassNames1:Array = ScriptedSparrowCharacter.listScriptClasses(); + if (scriptedCharClassNames1.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames1.length} (Sparrow) scripted characters...'); + for (charCls in scriptedCharClassNames1) + { + var character = ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + } - var scriptedCharClassNames2:Array = ScriptedPackerCharacter.listScriptClasses(); - if (scriptedCharClassNames2.length > 0) - { - trace(' Instantiating ${scriptedCharClassNames2.length} (Packer) scripted characters...'); - for (charCls in scriptedCharClassNames2) - { - var character = ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID); - characterScriptedClass.set(character.characterId, charCls); - } - } + var scriptedCharClassNames2:Array = ScriptedPackerCharacter.listScriptClasses(); + if (scriptedCharClassNames2.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames2.length} (Packer) scripted characters...'); + for (charCls in scriptedCharClassNames2) + { + var character = ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + } - var scriptedCharClassNames3:Array = ScriptedMultiSparrowCharacter.listScriptClasses(); - if (scriptedCharClassNames3.length > 0) - { - trace(' Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...'); - for (charCls in scriptedCharClassNames3) - { - var character = ScriptedMultiSparrowCharacter.init(charCls, DEFAULT_CHAR_ID); - if (character == null) - { - trace(' Failed to instantiate scripted character: ${charCls}'); - continue; - } - characterScriptedClass.set(character.characterId, charCls); - } - } + var scriptedCharClassNames3:Array = ScriptedMultiSparrowCharacter.listScriptClasses(); + if (scriptedCharClassNames3.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...'); + for (charCls in scriptedCharClassNames3) + { + var character = ScriptedMultiSparrowCharacter.init(charCls, DEFAULT_CHAR_ID); + if (character == null) + { + trace(' Failed to instantiate scripted character: ${charCls}'); + continue; + } + characterScriptedClass.set(character.characterId, charCls); + } + } - // NOTE: Only instantiate the ones not populated above. - // ScriptedBaseCharacter.listScriptClasses() will pick up scripts extending the other classes. - var scriptedCharClassNames:Array = ScriptedBaseCharacter.listScriptClasses(); - scriptedCharClassNames = scriptedCharClassNames.filter(function(charCls:String):Bool - { - return !(scriptedCharClassNames1.contains(charCls) - || scriptedCharClassNames2.contains(charCls) - || scriptedCharClassNames3.contains(charCls)); - }); + // NOTE: Only instantiate the ones not populated above. + // ScriptedBaseCharacter.listScriptClasses() will pick up scripts extending the other classes. + var scriptedCharClassNames:Array = ScriptedBaseCharacter.listScriptClasses(); + scriptedCharClassNames = scriptedCharClassNames.filter(function(charCls:String):Bool + { + return !(scriptedCharClassNames1.contains(charCls) + || scriptedCharClassNames2.contains(charCls) + || scriptedCharClassNames3.contains(charCls)); + }); - if (scriptedCharClassNames.length > 0) - { - trace(' Instantiating ${scriptedCharClassNames.length} (Base) scripted characters...'); - for (charCls in scriptedCharClassNames) - { - var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID); - if (character == null) - { - trace(' Failed to instantiate scripted character: ${charCls}'); - continue; - } - else - { - trace(' Successfully instantiated scripted character: ${charCls}'); - characterScriptedClass.set(character.characterId, charCls); - } - } - } + if (scriptedCharClassNames.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames.length} (Base) scripted characters...'); + for (charCls in scriptedCharClassNames) + { + var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID); + if (character == null) + { + trace(' Failed to instantiate scripted character: ${charCls}'); + continue; + } + else + { + trace(' Successfully instantiated scripted character: ${charCls}'); + characterScriptedClass.set(character.characterId, charCls); + } + } + } - trace(' Successfully loaded ${Lambda.count(characterCache)} stages.'); - } + trace(' Successfully loaded ${Lambda.count(characterCache)} stages.'); + } - public static function fetchCharacter(charId:String):Null - { - if (charId == null || charId == '') - { - // Gracefully handle songs that don't use this character. - return null; - } + public static function fetchCharacter(charId:String):Null + { + if (charId == null || charId == '') + { + // Gracefully handle songs that don't use this character. + return null; + } - if (characterCache.exists(charId)) - { - var charData:CharacterData = characterCache.get(charId); - var charScriptClass:String = characterScriptedClass.get(charId); + if (characterCache.exists(charId)) + { + var charData:CharacterData = characterCache.get(charId); + var charScriptClass:String = characterScriptedClass.get(charId); - var char:BaseCharacter; + var char:BaseCharacter; - if (charScriptClass != null) - { - switch (charData.renderType) - { - case CharacterRenderType.MULTISPARROW: - char = ScriptedMultiSparrowCharacter.init(charScriptClass, charId); - case CharacterRenderType.SPARROW: - char = ScriptedSparrowCharacter.init(charScriptClass, charId); - case CharacterRenderType.PACKER: - char = ScriptedPackerCharacter.init(charScriptClass, charId); - default: - // We're going to assume that the script class does the rendering. - char = ScriptedBaseCharacter.init(charScriptClass, charId); - } - } - else - { - switch (charData.renderType) - { - case CharacterRenderType.MULTISPARROW: - char = new MultiSparrowCharacter(charId); - case CharacterRenderType.SPARROW: - char = new SparrowCharacter(charId); - case CharacterRenderType.PACKER: - char = new PackerCharacter(charId); - default: - trace('[WARN] Creating character with undefined renderType ${charData.renderType}'); - char = new BaseCharacter(charId); - } - } + if (charScriptClass != null) + { + switch (charData.renderType) + { + case CharacterRenderType.MULTISPARROW: + char = ScriptedMultiSparrowCharacter.init(charScriptClass, charId); + case CharacterRenderType.SPARROW: + char = ScriptedSparrowCharacter.init(charScriptClass, charId); + case CharacterRenderType.PACKER: + char = ScriptedPackerCharacter.init(charScriptClass, charId); + default: + // We're going to assume that the script class does the rendering. + char = ScriptedBaseCharacter.init(charScriptClass, charId); + } + } + else + { + switch (charData.renderType) + { + case CharacterRenderType.MULTISPARROW: + char = new MultiSparrowCharacter(charId); + case CharacterRenderType.SPARROW: + char = new SparrowCharacter(charId); + case CharacterRenderType.PACKER: + char = new PackerCharacter(charId); + default: + trace('[WARN] Creating character with undefined renderType ${charData.renderType}'); + char = new BaseCharacter(charId); + } + } - trace('[CHARDATA] Successfully instantiated character: ${charId}'); + trace('Successfully instantiated character: ${charId}'); - // Call onCreate only in the fetchCharacter() function, not at application initialization. - ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE)); + // Call onCreate only in the fetchCharacter() function, not at application initialization. + ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE)); - return char; - } - else - { - trace('[CHARDATA] Failed to build character, not found in cache: ${charId}'); - return null; - } - } + return char; + } + else + { + trace('Failed to build character, not found in cache: ${charId}'); + return null; + } + } - public static function fetchCharacterData(charId:String):Null - { - if (characterCache.exists(charId)) - { - return characterCache.get(charId); - } - else - { - return null; - } - } + public static function fetchCharacterData(charId:String):Null + { + if (characterCache.exists(charId)) + { + return characterCache.get(charId); + } + else + { + return null; + } + } - public static function listCharacterIds():Array - { - return characterCache.keys().array(); - } + public static function listCharacterIds():Array + { + return characterCache.keys().array(); + } - static function clearCharacterCache():Void - { - if (characterCache != null) - { - characterCache.clear(); - } - if (characterScriptedClass != null) - { - characterScriptedClass.clear(); - } - } + static function clearCharacterCache():Void + { + if (characterCache != null) + { + characterCache.clear(); + } + if (characterScriptedClass != null) + { + characterScriptedClass.clear(); + } + } - /** - * Load a character's JSON file, parse its data, and return it. - * - * @param charId The character to load. - * @return The character data, or null if validation failed. - */ - public static function parseCharacterData(charId:String):Null - { - var rawJson:String = loadCharacterFile(charId); + /** + * Load a character's JSON file, parse its data, and return it. + * + * @param charId The character to load. + * @return The character data, or null if validation failed. + */ + public static function parseCharacterData(charId:String):Null + { + var rawJson:String = loadCharacterFile(charId); - var charData:CharacterData = migrateCharacterData(rawJson, charId); + var charData:CharacterData = migrateCharacterData(rawJson, charId); - return validateCharacterData(charId, charData); - } + return validateCharacterData(charId, charData); + } - static function loadCharacterFile(charPath:String):String - { - var charFilePath:String = Paths.json('characters/${charPath}'); - var rawJson = Assets.getText(charFilePath).trim(); + static function loadCharacterFile(charPath:String):String + { + var charFilePath:String = Paths.json('characters/${charPath}'); + var rawJson = Assets.getText(charFilePath).trim(); - while (!StringTools.endsWith(rawJson, "}")) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } + while (!StringTools.endsWith(rawJson, "}")) + { + rawJson = rawJson.substr(0, rawJson.length - 1); + } - return rawJson; - } + return rawJson; + } - static function migrateCharacterData(rawJson:String, charId:String) - { - // If you update the character data format in a breaking way, - // handle migration here by checking the `version` value. + static function migrateCharacterData(rawJson:String, charId:String) + { + // If you update the character data format in a breaking way, + // handle migration here by checking the `version` value. - try - { - var charData:CharacterData = cast Json.parse(rawJson); - return charData; - } - catch (e) - { - trace(' Error parsing data for character: ${charId}'); - trace(' ${e}'); - return null; - } - } + try + { + var charData:CharacterData = cast Json.parse(rawJson); + return charData; + } + catch (e) + { + trace(' Error parsing data for character: ${charId}'); + trace(' ${e}'); + return null; + } + } - /** - * The default time the character should sing for, in beats. - * Values that are too low will cause the character to stop singing between notes. - * Originally, this value was set to 1, but it was changed to 2 because that became - * too low after some other code changes. - */ - static final DEFAULT_SINGTIME:Float = 2.0; + /** + * The default time the character should sing for, in beats. + * Values that are too low will cause the character to stop singing between notes. + * Originally, this value was set to 1, but it was changed to 2 because that became + * too low after some other code changes. + */ + static final DEFAULT_SINGTIME:Float = 2.0; - static final DEFAULT_DANCEEVERY:Int = 1; - static final DEFAULT_FLIPX:Bool = false; - static final DEFAULT_FLIPY:Bool = false; - static final DEFAULT_FRAMERATE:Int = 24; - static final DEFAULT_ISPIXEL:Bool = false; - static final DEFAULT_LOOP:Bool = false; - static final DEFAULT_NAME:String = "Untitled Character"; - static final DEFAULT_OFFSETS:Array = [0, 0]; - static final DEFAULT_HEALTHICON_OFFSETS:Array = [0, 25]; - static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW; - static final DEFAULT_SCALE:Float = 1; - static final DEFAULT_SCROLL:Array = [0, 0]; - static final DEFAULT_STARTINGANIM:String = "idle"; + static final DEFAULT_DANCEEVERY:Int = 1; + static final DEFAULT_FLIPX:Bool = false; + static final DEFAULT_FLIPY:Bool = false; + static final DEFAULT_FRAMERATE:Int = 24; + static final DEFAULT_ISPIXEL:Bool = false; + static final DEFAULT_LOOP:Bool = false; + static final DEFAULT_NAME:String = "Untitled Character"; + static final DEFAULT_OFFSETS:Array = [0, 0]; + static final DEFAULT_HEALTHICON_OFFSETS:Array = [0, 25]; + static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW; + static final DEFAULT_SCALE:Float = 1; + static final DEFAULT_SCROLL:Array = [0, 0]; + static final DEFAULT_STARTINGANIM:String = "idle"; - /** - * Set unspecified parameters to their defaults. - * If the parameter is mandatory, print an error message. - * @param id - * @param input - * @return The validated character data - */ - static function validateCharacterData(id:String, input:CharacterData):Null - { - if (input == null) - { - // trace('[CHARDATA] ERROR: Could not parse character data for "${id}".'); - return null; - } + /** + * Set unspecified parameters to their defaults. + * If the parameter is mandatory, print an error message. + * @param id + * @param input + * @return The validated character data + */ + static function validateCharacterData(id:String, input:CharacterData):Null + { + if (input == null) + { + // trace('ERROR: Could not parse character data for "${id}".'); + return null; + } - if (input.version == null) - { - trace('[CHARDATA] WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}'); - input.version = CHARACTER_DATA_VERSION; - } + if (input.version == null) + { + trace('WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}'); + input.version = CHARACTER_DATA_VERSION; + } - if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE)) - { - trace('[CHARDATA] ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})'); - return null; - } + if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE)) + { + trace('ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})'); + return null; + } - if (input.name == null) - { - trace('[CHARDATA] WARN: Character data for "$id" missing name'); - input.name = DEFAULT_NAME; - } + if (input.name == null) + { + trace('WARN: Character data for "$id" missing name'); + input.name = DEFAULT_NAME; + } - if (input.renderType == null) - { - input.renderType = DEFAULT_RENDERTYPE; - } + if (input.renderType == null) + { + input.renderType = DEFAULT_RENDERTYPE; + } - if (input.assetPath == null) - { - trace('[CHARDATA] ERROR: Could not load character data for "$id": missing assetPath'); - return null; - } + if (input.assetPath == null) + { + trace('ERROR: Could not load character data for "$id": missing assetPath'); + return null; + } - if (input.offsets == null) - { - input.offsets = DEFAULT_OFFSETS; - } + if (input.offsets == null) + { + input.offsets = DEFAULT_OFFSETS; + } - if (input.cameraOffsets == null) - { - input.cameraOffsets = DEFAULT_OFFSETS; - } + if (input.cameraOffsets == null) + { + input.cameraOffsets = DEFAULT_OFFSETS; + } - if (input.healthIcon == null) - { - input.healthIcon = { - id: null, - scale: null, - flipX: null, - offsets: null - }; - } + if (input.healthIcon == null) + { + input.healthIcon = { + id: null, + scale: null, + flipX: null, + offsets: null + }; + } - if (input.healthIcon.id == null) - { - input.healthIcon.id = id; - } + if (input.healthIcon.id == null) + { + input.healthIcon.id = id; + } - if (input.healthIcon.scale == null) - { - input.healthIcon.scale = DEFAULT_SCALE; - } + if (input.healthIcon.scale == null) + { + input.healthIcon.scale = DEFAULT_SCALE; + } - if (input.healthIcon.flipX == null) - { - input.healthIcon.flipX = DEFAULT_FLIPX; - } + if (input.healthIcon.flipX == null) + { + input.healthIcon.flipX = DEFAULT_FLIPX; + } - if (input.healthIcon.offsets == null) - { - input.healthIcon.offsets = DEFAULT_OFFSETS; - } + if (input.healthIcon.offsets == null) + { + input.healthIcon.offsets = DEFAULT_OFFSETS; + } - if (input.startingAnimation == null) - { - input.startingAnimation = DEFAULT_STARTINGANIM; - } + if (input.startingAnimation == null) + { + input.startingAnimation = DEFAULT_STARTINGANIM; + } - if (input.scale == null) - { - input.scale = DEFAULT_SCALE; - } + if (input.scale == null) + { + input.scale = DEFAULT_SCALE; + } - if (input.isPixel == null) - { - input.isPixel = DEFAULT_ISPIXEL; - } + if (input.isPixel == null) + { + input.isPixel = DEFAULT_ISPIXEL; + } - if (input.danceEvery == null) - { - input.danceEvery = DEFAULT_DANCEEVERY; - } + if (input.danceEvery == null) + { + input.danceEvery = DEFAULT_DANCEEVERY; + } - if (input.singTime == null) - { - input.singTime = DEFAULT_SINGTIME; - } + if (input.singTime == null) + { + input.singTime = DEFAULT_SINGTIME; + } - if (input.animations == null || input.animations.length == 0) - { - trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animations'); - input.animations = []; - } + if (input.animations == null || input.animations.length == 0) + { + trace('ERROR: Could not load character data for "$id": missing animations'); + input.animations = []; + } - if (input.flipX == null) - { - input.flipX = DEFAULT_FLIPX; - } + if (input.flipX == null) + { + input.flipX = DEFAULT_FLIPX; + } - if (input.animations.length == 0 && input.startingAnimation != null) - { - return null; - } + if (input.animations.length == 0 && input.startingAnimation != null) + { + return null; + } - for (inputAnimation in input.animations) - { - if (inputAnimation.name == null) - { - trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animation name for prop "${input.name}"'); - return null; - } + for (inputAnimation in input.animations) + { + if (inputAnimation.name == null) + { + trace('ERROR: Could not load character data for "$id": missing animation name for prop "${input.name}"'); + return null; + } - if (inputAnimation.frameRate == null) - { - inputAnimation.frameRate = DEFAULT_FRAMERATE; - } + if (inputAnimation.frameRate == null) + { + inputAnimation.frameRate = DEFAULT_FRAMERATE; + } - if (inputAnimation.offsets == null) - { - inputAnimation.offsets = DEFAULT_OFFSETS; - } + if (inputAnimation.offsets == null) + { + inputAnimation.offsets = DEFAULT_OFFSETS; + } - if (inputAnimation.looped == null) - { - inputAnimation.looped = DEFAULT_LOOP; - } + if (inputAnimation.looped == null) + { + inputAnimation.looped = DEFAULT_LOOP; + } - if (inputAnimation.flipX == null) - { - inputAnimation.flipX = DEFAULT_FLIPX; - } + if (inputAnimation.flipX == null) + { + inputAnimation.flipX = DEFAULT_FLIPX; + } - if (inputAnimation.flipY == null) - { - inputAnimation.flipY = DEFAULT_FLIPY; - } - } + if (inputAnimation.flipY == null) + { + inputAnimation.flipY = DEFAULT_FLIPY; + } + } - // All good! - return input; - } + // All good! + return input; + } } enum abstract CharacterRenderType(String) from String to String { - var SPARROW = 'sparrow'; - var PACKER = 'packer'; - var MULTISPARROW = 'multisparrow'; - // TODO: FlxSpine? - // https://api.haxeflixel.com/flixel/addons/editors/spine/FlxSpine.html - // TODO: Aseprite? - // https://lib.haxe.org/p/openfl-aseprite/ - // TODO: Animate? - // https://lib.haxe.org/p/flxanimate - // TODO: REDACTED + var SPARROW = 'sparrow'; + var PACKER = 'packer'; + var MULTISPARROW = 'multisparrow'; + // TODO: FlxSpine? + // https://api.haxeflixel.com/flixel/addons/editors/spine/FlxSpine.html + // TODO: Aseprite? + // https://lib.haxe.org/p/openfl-aseprite/ + // TODO: Animate? + // https://lib.haxe.org/p/flxanimate + // TODO: REDACTED } typedef CharacterData = { - /** - * The sematic version number of the character data JSON format. - */ - var version:String; + /** + * The sematic version number of the character data JSON format. + */ + var version:String; - /** - * The readable name of the character. - */ - var name:String; + /** + * The readable name of the character. + */ + var name:String; - /** - * The type of rendering system to use for the character. - * @default sparrow - */ - var renderType:CharacterRenderType; + /** + * The type of rendering system to use for the character. + * @default sparrow + */ + var renderType:CharacterRenderType; - /** - * Behavior varies by render type: - * - SPARROW: Path to retrieve both the spritesheet and the XML data from. - * - PACKER: Path to retrieve both the spritsheet and the TXT data from. - */ - var assetPath:String; + /** + * Behavior varies by render type: + * - SPARROW: Path to retrieve both the spritesheet and the XML data from. + * - PACKER: Path to retrieve both the spritsheet and the TXT data from. + */ + var assetPath:String; - /** - * The scale of the graphic as a float. - * Pro tip: On pixel-art levels, save the sprites small and set this value to 6 or so to save memory. - * @default 1 - */ - var scale:Null; + /** + * The scale of the graphic as a float. + * Pro tip: On pixel-art levels, save the sprites small and set this value to 6 or so to save memory. + * @default 1 + */ + var scale:Null; - /** - * Optional data about the health icon for the character. - */ - var healthIcon:Null; + /** + * Optional data about the health icon for the character. + */ + var healthIcon:Null; - /** - * The global offset to the character's position, in pixels. - * @default [0, 0] - */ - var offsets:Null>; + /** + * The global offset to the character's position, in pixels. + * @default [0, 0] + */ + var offsets:Null>; - /** - * The amount to offset the camera by while focusing on this character. - * Default value focuses on the character directly. - * @default [0, 0] - */ - var cameraOffsets:Array; + /** + * The amount to offset the camera by while focusing on this character. + * Default value focuses on the character directly. + * @default [0, 0] + */ + var cameraOffsets:Array; - /** - * Setting this to true disables anti-aliasing for the character. - * @default false - */ - var isPixel:Null; + /** + * Setting this to true disables anti-aliasing for the character. + * @default false + */ + var isPixel:Null; - /** - * The frequency at which the character will play its idle animation, in beats. - * Increasing this number will make the character dance less often. - * - * @default 1 - */ - var danceEvery:Null; + /** + * The frequency at which the character will play its idle animation, in beats. + * Increasing this number will make the character dance less often. + * + * @default 1 + */ + var danceEvery:Null; - /** - * The minimum duration that a character will play a note animation for, in beats. - * If this number is too low, you may see the character start playing the idle animation between notes. - * If this number is too high, you may see the the character play the sing animation for too long after the notes are gone. - * - * Examples: - * - Daddy Dearest uses a value of `1.525`. - * @default 1.0 - */ - var singTime:Null; + /** + * The minimum duration that a character will play a note animation for, in beats. + * If this number is too low, you may see the character start playing the idle animation between notes. + * If this number is too high, you may see the the character play the sing animation for too long after the notes are gone. + * + * Examples: + * - Daddy Dearest uses a value of `1.525`. + * @default 1.0 + */ + var singTime:Null; - /** - * An optional array of animations which the character can play. - */ - var animations:Array; + /** + * An optional array of animations which the character can play. + */ + var animations:Array; - /** - * If animations are used, this is the name of the animation to play first. - * @default idle - */ - var startingAnimation:Null; + /** + * If animations are used, this is the name of the animation to play first. + * @default idle + */ + var startingAnimation:Null; - /** - * Whether or not the whole ass sprite is flipped by default. - * Useful for characters that could also be played (Pico) - * - * @default false - */ - var flipX:Null; + /** + * Whether or not the whole ass sprite is flipped by default. + * Useful for characters that could also be played (Pico) + * + * @default false + */ + var flipX:Null; }; typedef HealthIconData = { - /** - * The ID to use for the health icon. - * @default The character's ID - */ - var id:Null; + /** + * The ID to use for the health icon. + * @default The character's ID + */ + var id:Null; - /** - * The scale of the health icon. - */ - var scale:Null; + /** + * The scale of the health icon. + */ + var scale:Null; - /** - * Whether to flip the health icon horizontally. - * @default false - */ - var flipX:Null; + /** + * Whether to flip the health icon horizontally. + * @default false + */ + var flipX:Null; - /** - * The offset of the health icon, in pixels. - * @default [0, 25] - */ - var offsets:Null>; + /** + * The offset of the health icon, in pixels. + * @default [0, 25] + */ + var offsets:Null>; } diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx index 132845832..2b539d6e3 100644 --- a/source/funkin/play/character/MultiSparrowCharacter.hx +++ b/source/funkin/play/character/MultiSparrowCharacter.hx @@ -20,199 +20,199 @@ import funkin.util.assets.FlxAnimationUtil; */ class MultiSparrowCharacter extends BaseCharacter { - /** - * The actual group which holds all spritesheets this character uses. - */ - private var members:Map = new Map(); + /** + * The actual group which holds all spritesheets this character uses. + */ + private var members:Map = new Map(); - /** - * A map between animation names and what frame collection the animation should use. - */ - private var animAssetPath:Map = new Map(); + /** + * A map between animation names and what frame collection the animation should use. + */ + private var animAssetPath:Map = new Map(); - /** - * The current frame collection being used. - */ - private var activeMember:String; + /** + * The current frame collection being used. + */ + private var activeMember:String; - public function new(id:String) - { - super(id); - } + public function new(id:String) + { + super(id); + } - override function onCreate(event:ScriptEvent):Void - { - trace('Creating MULTI SPARROW CHARACTER: ' + this.characterId); + override function onCreate(event:ScriptEvent):Void + { + trace('Creating Multi-Sparrow character: ' + this.characterId); - buildSprites(); - super.onCreate(event); - } + buildSprites(); + super.onCreate(event); + } - function buildSprites() - { - buildSpritesheets(); - buildAnimations(); + function buildSprites() + { + buildSpritesheets(); + buildAnimations(); - if (_data.isPixel) - { - this.antialiasing = false; - } - else - { - this.antialiasing = true; - } - } + if (_data.isPixel) + { + this.antialiasing = false; + } + else + { + this.antialiasing = true; + } + } - function buildSpritesheets() - { - // Build the list of asset paths to use. - // Ignore nulls and duplicates. - var assetList = [_data.assetPath]; - for (anim in _data.animations) - { - if (anim.assetPath != null && !assetList.contains(anim.assetPath)) - { - assetList.push(anim.assetPath); - } - animAssetPath.set(anim.name, anim.assetPath); - } + function buildSpritesheets() + { + // Build the list of asset paths to use. + // Ignore nulls and duplicates. + var assetList = [_data.assetPath]; + for (anim in _data.animations) + { + if (anim.assetPath != null && !assetList.contains(anim.assetPath)) + { + assetList.push(anim.assetPath); + } + animAssetPath.set(anim.name, anim.assetPath); + } - // Load the Sparrow atlas for each path and store them in the members map. - for (asset in assetList) - { - var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared'); - // If we don't do this, the unused textures will be removed as soon as they're loaded. + // Load the Sparrow atlas for each path and store them in the members map. + for (asset in assetList) + { + var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared'); + // If we don't do this, the unused textures will be removed as soon as they're loaded. - if (texture == null) - { - trace('Multi-Sparrow atlas could not load texture: ${asset}'); - } - else - { - trace('Adding multi-sparrow atlas: ${asset}'); - texture.parent.destroyOnNoUse = false; - members.set(asset, texture); - } - } + if (texture == null) + { + trace('Multi-Sparrow atlas could not load texture: ${asset}'); + } + else + { + trace('Adding multi-sparrow atlas: ${asset}'); + texture.parent.destroyOnNoUse = false; + members.set(asset, texture); + } + } - // Use the default frame collection to start. - loadFramesByAssetPath(_data.assetPath); - } + // Use the default frame collection to start. + loadFramesByAssetPath(_data.assetPath); + } - /** - * Replace this sprite's animation frames with the ones at this asset path. - */ - function loadFramesByAssetPath(assetPath:String):Void - { - if (_data.assetPath == null) - { - trace('[ERROR] Multi-Sparrow character has no default asset path!'); - return; - } - if (assetPath == null) - { - // trace('Asset path is null, falling back to default. This is normal!'); - loadFramesByAssetPath(_data.assetPath); - return; - } + /** + * Replace this sprite's animation frames with the ones at this asset path. + */ + function loadFramesByAssetPath(assetPath:String):Void + { + if (_data.assetPath == null) + { + trace('[ERROR] Multi-Sparrow character has no default asset path!'); + return; + } + if (assetPath == null) + { + // trace('Asset path is null, falling back to default. This is normal!'); + loadFramesByAssetPath(_data.assetPath); + return; + } - if (this.activeMember == assetPath) - { - // trace('Already using this asset path: ${assetPath}'); - return; - } + if (this.activeMember == assetPath) + { + // trace('Already using this asset path: ${assetPath}'); + return; + } - if (members.exists(assetPath)) - { - // Switch to a new set of sprites. - // trace('Loading frames from asset path: ${assetPath}'); - this.frames = members.get(assetPath); - this.activeMember = assetPath; - this.setScale(_data.scale); - } - else - { - trace('[WARN] MultiSparrow character ${characterId} could not find asset path: ${assetPath}'); - } - } + if (members.exists(assetPath)) + { + // Switch to a new set of sprites. + // trace('Loading frames from asset path: ${assetPath}'); + this.frames = members.get(assetPath); + this.activeMember = assetPath; + this.setScale(_data.scale); + } + else + { + trace('[WARN] MultiSparrow character ${characterId} could not find asset path: ${assetPath}'); + } + } - /** - * Replace this sprite's animation frames with the ones needed to play this animation. - */ - function loadFramesByAnimName(animName) - { - if (animAssetPath.exists(animName)) - { - loadFramesByAssetPath(animAssetPath.get(animName)); - } - else - { - trace('[WARN] MultiSparrow character ${characterId} could not find animation: ${animName}'); - } - } + /** + * Replace this sprite's animation frames with the ones needed to play this animation. + */ + function loadFramesByAnimName(animName) + { + if (animAssetPath.exists(animName)) + { + loadFramesByAssetPath(animAssetPath.get(animName)); + } + else + { + trace('[WARN] MultiSparrow character ${characterId} could not find animation: ${animName}'); + } + } - function buildAnimations() - { - trace('[MULTISPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + function buildAnimations() + { + trace('[MULTISPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}'); - // We need to swap to the proper frame collection before adding the animations, I think? - for (anim in _data.animations) - { - loadFramesByAnimName(anim.name); - FlxAnimationUtil.addAtlasAnimation(this, anim); + // We need to swap to the proper frame collection before adding the animations, I think? + for (anim in _data.animations) + { + loadFramesByAnimName(anim.name); + FlxAnimationUtil.addAtlasAnimation(this, anim); - if (anim.offsets == null) - { - setAnimationOffsets(anim.name, 0, 0); - } - else - { - setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); - } - } + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } - var animNames = this.animation.getNameList(); - trace('[MULTISPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); - } + var animNames = this.animation.getNameList(); + trace('[MULTISPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); + } - public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void - { - // Make sure we ignore other animations if we're currently playing a forced one, - // unless we're forcing a new animation. - if (!this.canPlayOtherAnims && !ignoreOther) - return; + public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void + { + // Make sure we ignore other animations if we're currently playing a forced one, + // unless we're forcing a new animation. + if (!this.canPlayOtherAnims && !ignoreOther) + return; - loadFramesByAnimName(name); - super.playAnimation(name, restart, ignoreOther); - } + loadFramesByAnimName(name); + super.playAnimation(name, restart, ignoreOther); + } - override function set_frames(value:FlxFramesCollection):FlxFramesCollection - { - // DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK - // WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM - // if (animation != null) - // { - // animation.destroyAnimations(); - // } + override function set_frames(value:FlxFramesCollection):FlxFramesCollection + { + // DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK + // WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM + // if (animation != null) + // { + // animation.destroyAnimations(); + // } - if (value != null) - { - graphic = value.parent; - this.frames = value; - this.frame = value.getByIndex(0); - this.numFrames = value.numFrames; - resetHelpers(); - this.bakedRotationAngle = 0; - this.animation.frameIndex = 0; - graphicLoaded(); - } - else - { - this.frames = null; - this.frame = null; - this.graphic = null; - } + if (value != null) + { + graphic = value.parent; + this.frames = value; + this.frame = value.getByIndex(0); + this.numFrames = value.numFrames; + resetHelpers(); + this.bakedRotationAngle = 0; + this.animation.frameIndex = 0; + graphicLoaded(); + } + else + { + this.frames = null; + this.frame = null; + this.graphic = null; + } - return this.frames; - } + return this.frames; + } } diff --git a/source/funkin/play/character/PackerCharacter.hx b/source/funkin/play/character/PackerCharacter.hx index 91e44e9f2..00469964f 100644 --- a/source/funkin/play/character/PackerCharacter.hx +++ b/source/funkin/play/character/PackerCharacter.hx @@ -11,65 +11,65 @@ import funkin.play.character.BaseCharacter.CharacterType; */ class PackerCharacter extends BaseCharacter { - public function new(id:String) - { - super(id); - } + public function new(id:String) + { + super(id); + } - override function onCreate(event:ScriptEvent):Void - { - trace('Creating PACKER CHARACTER: ' + this.characterId); + override function onCreate(event:ScriptEvent):Void + { + trace('Creating Packer character: ' + this.characterId); - loadSpritesheet(); - loadAnimations(); + loadSpritesheet(); + loadAnimations(); - super.onCreate(event); - } + super.onCreate(event); + } - function loadSpritesheet() - { - trace('[PACKERCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}'); + function loadSpritesheet() + { + trace('[PACKERCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}'); - var tex:FlxFramesCollection = Paths.getPackerAtlas(_data.assetPath, 'shared'); - if (tex == null) - { - trace('Could not load Packer sprite: ${_data.assetPath}'); - return; - } + var tex:FlxFramesCollection = Paths.getPackerAtlas(_data.assetPath, 'shared'); + if (tex == null) + { + trace('Could not load Packer sprite: ${_data.assetPath}'); + return; + } - this.frames = tex; + this.frames = tex; - if (_data.isPixel) - { - this.antialiasing = false; - } - else - { - this.antialiasing = true; - } + if (_data.isPixel) + { + this.antialiasing = false; + } + else + { + this.antialiasing = true; + } - this.setScale(_data.scale); - } + this.setScale(_data.scale); + } - function loadAnimations() - { - trace('[PACKERCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + function loadAnimations() + { + trace('[PACKERCHAR] Loading ${_data.animations.length} animations for ${characterId}'); - FlxAnimationUtil.addAtlasAnimations(this, _data.animations); + FlxAnimationUtil.addAtlasAnimations(this, _data.animations); - for (anim in _data.animations) - { - if (anim.offsets == null) - { - setAnimationOffsets(anim.name, 0, 0); - } - else - { - setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); - } - } + for (anim in _data.animations) + { + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } - var animNames = this.animation.getNameList(); - trace('[PACKERCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); - } + var animNames = this.animation.getNameList(); + trace('[PACKERCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); + } } diff --git a/source/funkin/play/character/SparrowCharacter.hx b/source/funkin/play/character/SparrowCharacter.hx index 927a4c764..e9e9cf423 100644 --- a/source/funkin/play/character/SparrowCharacter.hx +++ b/source/funkin/play/character/SparrowCharacter.hx @@ -13,65 +13,65 @@ import flixel.graphics.frames.FlxFramesCollection; */ class SparrowCharacter extends BaseCharacter { - public function new(id:String) - { - super(id); - } + public function new(id:String) + { + super(id); + } - override function onCreate(event:ScriptEvent):Void - { - trace('Creating SPARROW CHARACTER: ' + this.characterId); + override function onCreate(event:ScriptEvent):Void + { + trace('Creating Sparrow character: ' + this.characterId); - loadSpritesheet(); - loadAnimations(); + loadSpritesheet(); + loadAnimations(); - super.onCreate(event); - } + super.onCreate(event); + } - function loadSpritesheet() - { - trace('[SPARROWCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}'); + function loadSpritesheet() + { + trace('[SPARROWCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}'); - var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath, 'shared'); - if (tex == null) - { - trace('Could not load Sparrow sprite: ${_data.assetPath}'); - return; - } + var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath, 'shared'); + if (tex == null) + { + trace('Could not load Sparrow sprite: ${_data.assetPath}'); + return; + } - this.frames = tex; + this.frames = tex; - if (_data.isPixel) - { - this.antialiasing = false; - } - else - { - this.antialiasing = true; - } + if (_data.isPixel) + { + this.antialiasing = false; + } + else + { + this.antialiasing = true; + } - this.setScale(_data.scale); - } + this.setScale(_data.scale); + } - function loadAnimations() - { - trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + function loadAnimations() + { + trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}'); - FlxAnimationUtil.addAtlasAnimations(this, _data.animations); + FlxAnimationUtil.addAtlasAnimations(this, _data.animations); - for (anim in _data.animations) - { - if (anim.offsets == null) - { - setAnimationOffsets(anim.name, 0, 0); - } - else - { - setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); - } - } + for (anim in _data.animations) + { + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } - var animNames = this.animation.getNameList(); - trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); - } + var animNames = this.animation.getNameList(); + trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); + } } diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx new file mode 100644 index 000000000..cc8f52164 --- /dev/null +++ b/source/funkin/play/event/FocusCameraSongEvent.hx @@ -0,0 +1,142 @@ +package funkin.play.event; + +import funkin.play.event.SongEvent; +import funkin.play.song.SongData; + +/** + * This class represents a handler for a type of song event. + * It is used by the ScriptedSongEvent class to handle user-defined events. + * + * Example: Focus on Boyfriend: + * ``` + * { + * "e": "FocusCamera", + * "v": { + * "char": 0, + * } + * } + * ``` + * + * Example: Focus on 10px above Girlfriend: + * ``` + * { + * "e": "FocusCamera", + * "v": { + * "char": 2, + * "y": -10, + * } + * } + * ``` + * + * Example: Focus on (100, 100): + * ``` + * { + * "e": "FocusCamera", + * "v": { + * "char": -1, + * "x": 100, + * "y": 100, + * } + * } + * ``` + */ +class FocusCameraSongEvent extends SongEvent +{ + public function new() + { + super('FocusCamera'); + } + + public override function handleEvent(data:SongEventData) + { + // Does nothing if there is no PlayState camera or stage. + if (PlayState.instance == null || PlayState.instance.currentStage == null) + return; + + var posX = data.getFloat('x'); + if (posX == null) + posX = 0.0; + var posY = data.getFloat('y'); + if (posY == null) + posY = 0.0; + + var char = data.getInt('char'); + + if (char == null) + char = cast data.value; + + switch (char) + { + case -1: // Position + trace('Focusing camera on static position.'); + var xTarget = posX; + var yTarget = posY; + + PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget); + case 0: // Boyfriend + // Focus the camera on the player. + trace('Focusing camera on player.'); + var xTarget = PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.x + posX; + var yTarget = PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.y + posY; + + PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget); + case 1: // Dad + // Focus the camera on the dad. + trace('Focusing camera on dad.'); + var xTarget = PlayState.instance.currentStage.getDad().cameraFocusPoint.x + posX; + var yTarget = PlayState.instance.currentStage.getDad().cameraFocusPoint.y + posY; + + PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget); + case 2: // Girlfriend + // Focus the camera on the girlfriend. + trace('Focusing camera on girlfriend.'); + var xTarget = PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.x + posX; + var yTarget = PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.y + posY; + + PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget); + default: + trace('Unknown camera focus: ' + data); + } + } + + public override function getTitle():String + { + return "Focus Camera"; + } + + /** + * ``` + * { + * "char": ENUM, // Which character to point to + * "x": FLOAT, // Optional x offset + * "y": FLOAT, // Optional y offset + * } + * @return SongEventSchema + */ + public override function getEventSchema():SongEventSchema + { + return [ + { + name: "char", + title: "Character", + defaultValue: 0, + type: SongEventFieldType.ENUM, + keys: ["Position" => -1, "Boyfriend" => 0, "Dad" => 1, "Girlfriend" => 2] + }, + { + name: "x", + title: "X Position", + defaultValue: 0, + step: 10.0, + type: SongEventFieldType.FLOAT, + }, + { + name: "y", + title: "Y Position", + defaultValue: 0, + step: 10.0, + type: SongEventFieldType.FLOAT, + } + ]; + } +} diff --git a/source/funkin/play/event/PlayAnimationSongEvent.hx b/source/funkin/play/event/PlayAnimationSongEvent.hx new file mode 100644 index 000000000..fcf246d57 --- /dev/null +++ b/source/funkin/play/event/PlayAnimationSongEvent.hx @@ -0,0 +1,111 @@ +package funkin.play.event; + +import flixel.FlxSprite; +import funkin.play.character.BaseCharacter; +import funkin.play.event.SongEvent; +import funkin.play.song.SongData; + +class PlayAnimationSongEvent extends SongEvent +{ + public function new() + { + super('PlayAnimation'); + } + + public override function handleEvent(data:SongEventData) + { + // Does nothing if there is no PlayState camera or stage. + if (PlayState.instance == null || PlayState.instance.currentStage == null) + return; + + var targetName = data.getString('target'); + var anim = data.getString('anim'); + var force = data.getBool('force'); + if (force == null) + force = false; + + var target:FlxSprite = null; + + switch (targetName) + { + case 'boyfriend': + trace('Playing animation $anim on boyfriend.'); + target = PlayState.instance.currentStage.getBoyfriend(); + case 'bf': + trace('Playing animation $anim on boyfriend.'); + target = PlayState.instance.currentStage.getBoyfriend(); + case 'player': + trace('Playing animation $anim on boyfriend.'); + target = PlayState.instance.currentStage.getBoyfriend(); + case 'dad': + trace('Playing animation $anim on dad.'); + target = PlayState.instance.currentStage.getDad(); + case 'opponent': + trace('Playing animation $anim on dad.'); + target = PlayState.instance.currentStage.getDad(); + case 'girlfriend': + trace('Playing animation $anim on girlfriend.'); + target = PlayState.instance.currentStage.getGirlfriend(); + case 'gf': + trace('Playing animation $anim on girlfriend.'); + target = PlayState.instance.currentStage.getGirlfriend(); + default: + target = PlayState.instance.currentStage.getNamedProp(targetName); + if (target == null) + trace('Unknown animation target: $targetName'); + else + trace('Fetched animation target $targetName from stage.'); + } + + if (target != null) + { + if (Std.isOfType(target, BaseCharacter)) + { + var targetChar:BaseCharacter = cast target; + targetChar.playAnimation(anim, force, force); + } + else + { + target.animation.play(anim, force); + } + } + } + + public override function getTitle():String + { + return "Play Animation"; + } + + /** + * ``` + * { + * "target": STRING, // Name of character or prop to point to. + * "anim": STRING, // Name of animation to play. + * "force": BOOL, // Whether to force the animation to play. + * } + * @return SongEventSchema + */ + public override function getEventSchema():SongEventSchema + { + return [ + { + name: 'target', + title: 'Target', + type: SongEventFieldType.STRING, + defaultValue: 'boyfriend', + }, + { + name: 'anim', + title: 'Animation', + type: SongEventFieldType.STRING, + defaultValue: 'idle', + }, + { + name: 'force', + title: 'Force', + type: SongEventFieldType.BOOL, + defaultValue: false + } + ]; + } +} diff --git a/source/funkin/play/event/ScriptedSongEvent.hx b/source/funkin/play/event/ScriptedSongEvent.hx new file mode 100644 index 000000000..079e35110 --- /dev/null +++ b/source/funkin/play/event/ScriptedSongEvent.hx @@ -0,0 +1,9 @@ +package funkin.play.event; + +import funkin.play.song.Song; +import polymod.hscript.HScriptedClass; + +@:hscriptClass +class ScriptedSongEvent extends SongEvent implements HScriptedClass +{ +} diff --git a/source/funkin/play/event/SongEvent.hx b/source/funkin/play/event/SongEvent.hx index 4c0e29575..a06cfb23a 100644 --- a/source/funkin/play/event/SongEvent.hx +++ b/source/funkin/play/event/SongEvent.hx @@ -1,303 +1,270 @@ package funkin.play.event; -import flixel.FlxSprite; -import funkin.play.PlayState; -import funkin.play.character.BaseCharacter; -import funkin.play.song.SongData.RawSongEventData; -import haxe.DynamicAccess; +import funkin.util.macro.ClassMacro; +import funkin.play.song.SongData.SongEventData; -typedef RawSongEvent = +/** + * This class represents a handler for a type of song event. + * It is used by the ScriptedSongEvent class to handle user-defined events. + */ +class SongEvent { - > RawSongEventData, + public var id:String; - /** - * Whether the event has been activated or not. - */ - var a:Bool; + public function new(id:String) + { + this.id = id; + } + + public function handleEvent(data:SongEventData) + { + throw 'SongEvent.handleEvent() must be overridden!'; + } + + public function getEventSchema():SongEventSchema + { + return null; + } + + public function getTitle():String + { + return this.id.toTitleCase(); + } + + public function toString():String + { + return 'SongEvent(${this.id})'; + } } -@:forward -abstract SongEvent(RawSongEvent) +class SongEventParser { - public function new(time:Float, event:String, value:Dynamic = null) - { - this = { - t: time, - e: event, - v: value, - a: false - }; - } + /** + * Every built-in event class must be added to this list. + * Thankfully, with the power of `SongEventMacro`, this is done automatically. + */ + private static final BUILTIN_EVENTS:List> = ClassMacro.listSubclassesOf(SongEvent); - public var time(get, set):Float; + /** + * Map of internal handlers for song events. + * These may be either `ScriptedSongEvents` or built-in classes extending `SongEvent`. + */ + static final eventCache:Map = new Map(); - public function get_time():Float - { - return this.t; - } + public static function loadEventCache():Void + { + clearEventCache(); - public function set_time(value:Float):Float - { - return this.t = value; - } + // + // BASE GAME EVENTS + // + registerBaseEvents(); + registerScriptedEvents(); + } - public var event(get, set):String; + static function registerBaseEvents() + { + trace('Instantiating ${BUILTIN_EVENTS.length} built-in song events...'); + for (eventCls in BUILTIN_EVENTS) + { + var eventClsName:String = Type.getClassName(eventCls); + if (eventClsName == 'funkin.play.event.SongEvent' || eventClsName == 'funkin.play.event.ScriptedSongEvent') + continue; - public function get_event():String - { - return this.e; - } + var event:SongEvent = Type.createInstance(eventCls, ["UNKNOWN"]); - public function set_event(value:String):String - { - return this.e = value; - } + if (event != null) + { + trace(' Loaded built-in song event: (${event.id})'); + eventCache.set(event.id, event); + } + else + { + trace(' Failed to load built-in song event: ${Type.getClassName(eventCls)}'); + } + } + } - public var value(get, set):Dynamic; + static function registerScriptedEvents() + { + var scriptedEventClassNames:Array = ScriptedSongEvent.listScriptClasses(); + if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0) + return; - public function get_value():Dynamic - { - return this.v; - } + trace('Instantiating ${scriptedEventClassNames.length} scripted song events...'); + for (eventCls in scriptedEventClassNames) + { + var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN"); - public function set_value(value:Dynamic):Dynamic - { - return this.v = value; - } + if (event != null) + { + trace(' Loaded scripted song event: ${event.id}'); + eventCache.set(event.id, event); + } + else + { + trace(' Failed to instantiate scripted song event class: ${eventCls}'); + } + } + } - public inline function getBool():Bool - { - return cast this.v; - } + public static function listEventIds():Array + { + return eventCache.keys().array(); + } - public inline function getInt():Int - { - return cast this.v; - } + public static function listEvents():Array + { + return eventCache.values(); + } - public inline function getFloat():Float - { - return cast this.v; - } + public static function getEvent(id:String):SongEvent + { + return eventCache.get(id); + } - public inline function getString():String - { - return cast this.v; - } + public static function getEventSchema(id:String):SongEventSchema + { + var event:SongEvent = getEvent(id); + if (event == null) + return null; - public inline function getArray():Array - { - return cast this.v; - } + return event.getEventSchema(); + } - public inline function getMap():DynamicAccess - { - return cast this.v; - } + static function clearEventCache() + { + eventCache.clear(); + } - public inline function getBoolArray():Array - { - return cast this.v; - } + public static function handleEvent(data:SongEventData):Void + { + var eventType:String = data.event; + var eventHandler:SongEvent = eventCache.get(eventType); + + if (eventHandler != null) + { + eventHandler.handleEvent(data); + } + else + { + trace('WARNING: No event handler for event with id: ${eventType}'); + } + + data.activated = true; + } + + public static inline function handleEvents(events:Array):Void + { + for (event in events) + { + handleEvent(event); + } + } + + /** + * Given a list of song events and the current timestamp, + * return a list of events that should be handled. + */ + public static function queryEvents(events:Array, currentTime:Float):Array + { + return events.filter(function(event:SongEventData):Bool + { + // If the event is already activated, don't activate it again. + if (event.activated) + return false; + + // If the event is in the future, don't activate it. + if (event.time > currentTime) + return false; + + return true; + }); + } + + /** + * Reset activation of all the provided events. + */ + public static function resetEvents(events:Array):Void + { + for (event in events) + { + event.activated = false; + // TODO: Add an onReset() method to SongEvent? + } + } } -typedef SongEventCallback = SongEvent->Void; - -class SongEventHandler +enum abstract SongEventFieldType(String) from String to String { - private static final eventCallbacks:Map = new Map(); + /** + * The STRING type will display as a text field. + */ + var STRING = "string"; - public static function registerCallback(event:String, callback:SongEventCallback):Void - { - eventCallbacks.set(event, callback); - } + /** + * The INTEGER type will display as a text field that only accepts numbers. + */ + var INTEGER = "integer"; - public static function unregisterCallback(event:String):Void - { - eventCallbacks.remove(event); - } + /** + * The FLOAT type will display as a text field that only accepts numbers. + */ + var FLOAT = "float"; - public static function clearCallbacks():Void - { - eventCallbacks.clear(); - } + /** + * The BOOL type will display as a checkbox. + */ + var BOOL = "bool"; - /** - * Register each of the event callbacks provided by the base game. - */ - public static function registerBaseEventCallbacks():Void - { - // TODO: Add a system for mods to easily add their own event callbacks. - // Should be easy as creating character or stage scripts. - registerCallback('FocusCamera', VanillaEventCallbacks.focusCamera); - registerCallback('PlayAnimation', VanillaEventCallbacks.playAnimation); - } - - /** - * Given a list of song events and the current timestamp, - * return a list of events that should be activated. - */ - public static function queryEvents(events:Array, currentTime:Float):Array - { - return events.filter(function(event:SongEvent):Bool - { - // If the event is already activated, don't activate it again. - if (event.a) - return false; - - // If the event is in the future, don't activate it. - if (event.time > currentTime) - return false; - - return true; - }); - } - - public static function activateEvents(events:Array):Void - { - for (event in events) - { - activateEvent(event); - } - } - - public static function activateEvent(event:SongEvent):Void - { - if (event.a) - { - trace('Event already activated: ' + event); - return; - } - - // Prevent the event from being activated again. - event.a = true; - - // Perform the action. - if (eventCallbacks.exists(event.event)) - { - eventCallbacks.get(event.event)(event); - } - } - - public static function resetEvents(events:Array):Void - { - for (event in events) - { - resetEvent(event); - } - } - - public static function resetEvent(event:SongEvent):Void - { - // TODO: Add a system for mods to easily add their reset callbacks. - event.a = false; - } + /** + * The ENUM type will display as a dropdown. + * Make sure to specify the `keys` field in the schema. + */ + var ENUM = "enum"; } -class VanillaEventCallbacks +typedef SongEventSchemaField = { - /** - * Event Name: "FocusCamera" - * Event Value: Int - * 0: Focus on the player. - * 1: Focus on the opponent. - * 2: Focus on the girlfriend. - */ - public static function focusCamera(event:SongEvent):Void - { - // Does nothing if there is no PlayState camera or stage. - if (PlayState.instance == null || PlayState.instance.currentStage == null) - return; + /** + * The name of the property as it should be saved in the event data. + */ + name:String, - switch (event.getInt()) - { - case 0: // Boyfriend - // Focus the camera on the player. - trace('[EVENT] Focusing camera on player.'); - PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.x, - PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.y); - case 1: // Dad - // Focus the camera on the dad. - trace('[EVENT] Focusing camera on dad.'); - PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getDad().cameraFocusPoint.x, - PlayState.instance.currentStage.getDad().cameraFocusPoint.y); - case 2: // Girlfriend - // Focus the camera on the girlfriend. - trace('[EVENT] Focusing camera on girlfriend.'); - PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.x, - PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.y); - default: - trace('[EVENT] Unknown camera focus: ' + event.value); - } - } + /** + * The title of the field to display in the UI. + */ + title:String, - /** - * Event Name: "playAnimation" - * Event Value: Object - * { - * target: String, // "player", "dad", "girlfriend", or - * animation: String, - * force: Bool // optional - * } - */ - public static function playAnimation(event:SongEvent):Void - { - // Does nothing if there is no PlayState camera or stage. - if (PlayState.instance == null || PlayState.instance.currentStage == null) - return; + /** + * The type of the field. + */ + type:SongEventFieldType, - var data:Dynamic = event.value; - - var targetName:String = Reflect.field(data, 'target'); - var anim:String = Reflect.field(data, 'anim'); - var force:Null = Reflect.field(data, 'force'); - if (force == null) - force = false; - - var target:FlxSprite = null; - - switch (targetName) - { - case 'boyfriend': - trace('[EVENT] Playing animation $anim on boyfriend.'); - target = PlayState.instance.currentStage.getBoyfriend(); - case 'bf': - trace('[EVENT] Playing animation $anim on boyfriend.'); - target = PlayState.instance.currentStage.getBoyfriend(); - case 'player': - trace('[EVENT] Playing animation $anim on boyfriend.'); - target = PlayState.instance.currentStage.getBoyfriend(); - case 'dad': - trace('[EVENT] Playing animation $anim on dad.'); - target = PlayState.instance.currentStage.getDad(); - case 'opponent': - trace('[EVENT] Playing animation $anim on dad.'); - target = PlayState.instance.currentStage.getDad(); - case 'girlfriend': - trace('[EVENT] Playing animation $anim on girlfriend.'); - target = PlayState.instance.currentStage.getGirlfriend(); - case 'gf': - trace('[EVENT] Playing animation $anim on girlfriend.'); - target = PlayState.instance.currentStage.getGirlfriend(); - default: - target = PlayState.instance.currentStage.getNamedProp(targetName); - if (target == null) - trace('[EVENT] Unknown animation target: $targetName'); - else - trace('[EVENT] Fetched animation target $targetName from stage.'); - } - - if (target != null) - { - if (Std.isOfType(target, BaseCharacter)) - { - var targetChar:BaseCharacter = cast target; - targetChar.playAnimation(anim, force, force); - } - else - { - target.animation.play(anim, force); - } - } - } + /** + * Used for ENUM values. + * The key is the display name and the value is the actual value. + */ + ?keys:Map, + /** + * Used for INTEGER and FLOAT values. + * The minimum value that can be entered. + */ + ?min:Float, + /** + * Used for INTEGER and FLOAT values. + * The maximum value that can be entered. + */ + ?max:Float, + /** + * Used for INTEGER and FLOAT values. + * The step value that will be used when incrementing/decrementing the value. + */ + ?step:Float, + /** + * An optional default value for the field. + */ + ?defaultValue:Dynamic, } + +typedef SongEventSchema = Array; diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 08ce6818f..f15f4dafb 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -22,239 +22,239 @@ import funkin.play.song.SongData.SongTimeFormat; */ class Song // implements IPlayStateScriptedClass { - public final songId:String; + public final songId:String; - final _metadata:Array; + final _metadata:Array; - final variations:Array; - final difficulties:Map; + final variations:Array; + final difficulties:Map; - public function new(id:String) - { - this.songId = id; + public function new(id:String) + { + this.songId = id; - variations = []; - difficulties = new Map(); + variations = []; + difficulties = new Map(); - _metadata = SongDataParser.parseSongMetadata(songId); - if (_metadata == null || _metadata.length == 0) - { - throw 'Could not find song data for songId: $songId'; - } + _metadata = SongDataParser.parseSongMetadata(songId); + if (_metadata == null || _metadata.length == 0) + { + throw 'Could not find song data for songId: $songId'; + } - populateFromMetadata(); - } + populateFromMetadata(); + } - public function getRawMetadata():Array - { - return _metadata; - } + public function getRawMetadata():Array + { + return _metadata; + } - /** - * Populate the song data from the provided metadata, - * including data from individual difficulties. Does not load chart data. - */ - function populateFromMetadata():Void - { - // Variations may have different artist, time format, generatedBy, etc. - for (metadata in _metadata) - { - for (diffId in metadata.playData.difficulties) - { - var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation); + /** + * Populate the song data from the provided metadata, + * including data from individual difficulties. Does not load chart data. + */ + function populateFromMetadata():Void + { + // Variations may have different artist, time format, generatedBy, etc. + for (metadata in _metadata) + { + for (diffId in metadata.playData.difficulties) + { + var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation); - variations.push(metadata.variation); + variations.push(metadata.variation); - difficulty.songName = metadata.songName; - difficulty.songArtist = metadata.artist; - difficulty.timeFormat = metadata.timeFormat; - difficulty.divisions = metadata.divisions; - difficulty.timeChanges = metadata.timeChanges; - difficulty.loop = metadata.loop; - difficulty.generatedBy = metadata.generatedBy; + difficulty.songName = metadata.songName; + difficulty.songArtist = metadata.artist; + difficulty.timeFormat = metadata.timeFormat; + difficulty.divisions = metadata.divisions; + difficulty.timeChanges = metadata.timeChanges; + difficulty.loop = metadata.loop; + difficulty.generatedBy = metadata.generatedBy; - difficulty.stage = metadata.playData.stage; - // difficulty.noteSkin = metadata.playData.noteSkin; + difficulty.stage = metadata.playData.stage; + // difficulty.noteSkin = metadata.playData.noteSkin; - difficulty.chars = new Map(); - for (charId in metadata.playData.playableChars.keys()) - { - var char = metadata.playData.playableChars.get(charId); + difficulty.chars = new Map(); + for (charId in metadata.playData.playableChars.keys()) + { + var char = metadata.playData.playableChars.get(charId); - difficulty.chars.set(charId, char); - } + difficulty.chars.set(charId, char); + } - difficulties.set(diffId, difficulty); - } - } - } + difficulties.set(diffId, difficulty); + } + } + } - /** - * Parse and cache the chart for all difficulties of this song. - */ - public function cacheCharts(?force:Bool = false):Void - { - if (force) - { - clearCharts(); - } + /** + * Parse and cache the chart for all difficulties of this song. + */ + public function cacheCharts(?force:Bool = false):Void + { + if (force) + { + clearCharts(); + } - trace('Caching ${variations.length} chart files for song $songId'); - for (variation in variations) - { - var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation); - var chartNotes = chartData.notes; + trace('Caching ${variations.length} chart files for song $songId'); + for (variation in variations) + { + var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation); + var chartNotes = chartData.notes; - for (diffId in chartNotes.keys()) - { - // Retrieve the cached difficulty data. - var difficulty:Null = difficulties.get(diffId); - if (difficulty == null) - { - trace('Could not find difficulty $diffId for song $songId'); - continue; - } - // Add the chart data to the difficulty. - difficulty.notes = chartData.notes.get(diffId); - difficulty.scrollSpeed = chartData.getScrollSpeed(diffId); + for (diffId in chartNotes.keys()) + { + // Retrieve the cached difficulty data. + var difficulty:Null = difficulties.get(diffId); + if (difficulty == null) + { + trace('Could not find difficulty $diffId for song $songId'); + continue; + } + // Add the chart data to the difficulty. + difficulty.notes = chartData.notes.get(diffId); + difficulty.scrollSpeed = chartData.getScrollSpeed(diffId); - difficulty.events = chartData.events; - } - } - trace('Done caching charts.'); - } + difficulty.events = chartData.events; + } + } + trace('Done caching charts.'); + } - /** - * Retrieve the metadata for a specific difficulty, including the chart if it is loaded. - */ - public inline function getDifficulty(?diffId:String):SongDifficulty - { - if (diffId == null) - diffId = difficulties.keys().array()[0]; + /** + * Retrieve the metadata for a specific difficulty, including the chart if it is loaded. + */ + public inline function getDifficulty(?diffId:String):SongDifficulty + { + if (diffId == null) + diffId = difficulties.keys().array()[0]; - return difficulties.get(diffId); - } + return difficulties.get(diffId); + } - /** - * Purge the cached chart data for each difficulty of this song. - */ - public function clearCharts():Void - { - for (diff in difficulties) - { - diff.clearChart(); - } - } + /** + * Purge the cached chart data for each difficulty of this song. + */ + public function clearCharts():Void + { + for (diff in difficulties) + { + diff.clearChart(); + } + } - public function toString():String - { - return 'Song($songId)'; - } + public function toString():String + { + return 'Song($songId)'; + } } class SongDifficulty { - /** - * The parent song for this difficulty. - */ - public final song:Song; + /** + * The parent song for this difficulty. + */ + public final song:Song; - /** - * The difficulty ID, such as `easy` or `hard`. - */ - public final difficulty:String; + /** + * The difficulty ID, such as `easy` or `hard`. + */ + public final difficulty:String; - /** - * The metadata file that contains this difficulty. - */ - public final variation:String; + /** + * The metadata file that contains this difficulty. + */ + public final variation:String; - /** - * The note chart for this difficulty. - */ - public var notes:Array; + /** + * The note chart for this difficulty. + */ + public var notes:Array; - /** - * The event chart for this difficulty. - */ - public var events:Array; + /** + * The event chart for this difficulty. + */ + public var events:Array; - public var songName:String = SongValidator.DEFAULT_SONGNAME; - public var songArtist:String = SongValidator.DEFAULT_ARTIST; - public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT; - public var divisions:Int = SongValidator.DEFAULT_DIVISIONS; - public var loop:Bool = SongValidator.DEFAULT_LOOP; - public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY; + public var songName:String = SongValidator.DEFAULT_SONGNAME; + public var songArtist:String = SongValidator.DEFAULT_ARTIST; + public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT; + public var divisions:Int = SongValidator.DEFAULT_DIVISIONS; + public var loop:Bool = SongValidator.DEFAULT_LOOP; + public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY; - public var timeChanges:Array = []; + public var timeChanges:Array = []; - public var stage:String = SongValidator.DEFAULT_STAGE; - public var chars:Map = null; + public var stage:String = SongValidator.DEFAULT_STAGE; + public var chars:Map = null; - public var scrollSpeed:Float = SongValidator.DEFAULT_SCROLLSPEED; + public var scrollSpeed:Float = SongValidator.DEFAULT_SCROLLSPEED; - public function new(song:Song, diffId:String, variation:String) - { - this.song = song; - this.difficulty = diffId; - this.variation = variation; - } + public function new(song:Song, diffId:String, variation:String) + { + this.song = song; + this.difficulty = diffId; + this.variation = variation; + } - public function clearChart():Void - { - notes = null; - } + public function clearChart():Void + { + notes = null; + } - public function getStartingBPM():Float - { - if (timeChanges.length == 0) - { - return 0; - } + public function getStartingBPM():Float + { + if (timeChanges.length == 0) + { + return 0; + } - return timeChanges[0].bpm; - } + return timeChanges[0].bpm; + } - public function getPlayableChar(id:String):SongPlayableChar - { - return chars.get(id); - } + public function getPlayableChar(id:String):SongPlayableChar + { + return chars.get(id); + } - public function getPlayableChars():Array - { - return chars.keys().array(); - } + public function getPlayableChars():Array + { + return chars.keys().array(); + } - public function getEvents():Array - { - return cast events; - } + public function getEvents():Array + { + return cast events; + } - public inline function cacheInst() - { - FlxG.sound.cache(Paths.inst(this.song.songId)); - } + public inline function cacheInst() + { + FlxG.sound.cache(Paths.inst(this.song.songId)); + } - public inline function playInst(volume:Float = 1.0, looped:Bool = false) - { - FlxG.sound.playMusic(Paths.inst(this.song.songId), volume, looped); - } + public inline function playInst(volume:Float = 1.0, looped:Bool = false) + { + FlxG.sound.playMusic(Paths.inst(this.song.songId), volume, looped); + } - public inline function cacheVocals() - { - FlxG.sound.cache(Paths.voices(this.song.songId)); - } + public inline function cacheVocals() + { + FlxG.sound.cache(Paths.voices(this.song.songId)); + } - public function buildVoiceList():Array - { - // TODO: Implement. + public function buildVoiceList():Array + { + // TODO: Implement. - return [""]; - } + return [""]; + } - public function buildVocals(charId:String = "bf"):VoicesGroup - { - var result:VoicesGroup = VoicesGroup.build(this.song.songId, this.buildVoiceList()); - return result; - } + public function buildVocals(charId:String = "bf"):VoicesGroup + { + var result:VoicesGroup = VoicesGroup.build(this.song.songId, this.buildVoiceList()); + return result; + } } diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx index 775e78c11..480c3aab5 100644 --- a/source/funkin/play/song/SongData.hx +++ b/source/funkin/play/song/SongData.hx @@ -13,743 +13,771 @@ import thx.semver.Version; */ class SongDataParser { - /** - * A list containing all the songs available to the game. - */ - static final songCache:Map = new Map(); + /** + * A list containing all the songs available to the game. + */ + static final songCache:Map = new Map(); - static final DEFAULT_SONG_ID = 'UNKNOWN'; - static final SONG_DATA_PATH = 'songs/'; - static final SONG_DATA_SUFFIX = '-metadata.json'; + static final DEFAULT_SONG_ID = 'UNKNOWN'; + static final SONG_DATA_PATH = 'songs/'; + static final SONG_DATA_SUFFIX = '-metadata.json'; - /** - * Parses and preloads the game's song metadata and scripts when the game starts. - * - * If you want to force song metadata to be reloaded, you can just call this function again. - */ - public static function loadSongCache():Void - { - clearSongCache(); - trace("[SONGDATA] Loading song cache..."); + /** + * Parses and preloads the game's song metadata and scripts when the game starts. + * + * If you want to force song metadata to be reloaded, you can just call this function again. + */ + public static function loadSongCache():Void + { + clearSongCache(); + trace("Loading song cache..."); - // - // SCRIPTED SONGS - // - var scriptedSongClassNames:Array = ScriptedSong.listScriptClasses(); - trace(' Instantiating ${scriptedSongClassNames.length} scripted songs...'); - for (songCls in scriptedSongClassNames) - { - var song:Song = ScriptedSong.init(songCls, DEFAULT_SONG_ID); - if (song != null) - { - trace(' Loaded scripted song: ${song.songId}'); - songCache.set(song.songId, song); - } - else - { - trace(' Failed to instantiate scripted song class: ${songCls}'); - } - } + // + // SCRIPTED SONGS + // + var scriptedSongClassNames:Array = ScriptedSong.listScriptClasses(); + trace(' Instantiating ${scriptedSongClassNames.length} scripted songs...'); + for (songCls in scriptedSongClassNames) + { + var song:Song = ScriptedSong.init(songCls, DEFAULT_SONG_ID); + if (song != null) + { + trace(' Loaded scripted song: ${song.songId}'); + songCache.set(song.songId, song); + } + else + { + trace(' Failed to instantiate scripted song class: ${songCls}'); + } + } - // - // UNSCRIPTED SONGS - // - var songIdList:Array = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX).map(function(songDataPath:String):String - { - return songDataPath.split('/')[0]; - }); - var unscriptedSongIds:Array = songIdList.filter(function(songId:String):Bool - { - return !songCache.exists(songId); - }); - trace(' Instantiating ${unscriptedSongIds.length} non-scripted songs...'); - for (songId in unscriptedSongIds) - { - try - { - var song = new Song(songId); - if (song != null) - { - trace(' Loaded song data: ${song.songId}'); - songCache.set(song.songId, song); - } - } - catch (e) - { - trace(' An error occurred while loading song data: ${songId}'); - trace(e); - // Assume error was already logged. - continue; - } - } + // + // UNSCRIPTED SONGS + // + var songIdList:Array = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX).map(function(songDataPath:String):String + { + return songDataPath.split('/')[0]; + }); + var unscriptedSongIds:Array = songIdList.filter(function(songId:String):Bool + { + return !songCache.exists(songId); + }); + trace(' Instantiating ${unscriptedSongIds.length} non-scripted songs...'); + for (songId in unscriptedSongIds) + { + try + { + var song = new Song(songId); + if (song != null) + { + trace(' Loaded song data: ${song.songId}'); + songCache.set(song.songId, song); + } + } + catch (e) + { + trace(' An error occurred while loading song data: ${songId}'); + trace(e); + // Assume error was already logged. + continue; + } + } - trace(' Successfully loaded ${Lambda.count(songCache)} stages.'); - } + trace(' Successfully loaded ${Lambda.count(songCache)} stages.'); + } - /** - * Retrieves a particular song from the cache. - */ - public static function fetchSong(songId:String):Null - { - if (songCache.exists(songId)) - { - var song:Song = songCache.get(songId); - trace('[SONGDATA] Successfully fetch song: ${songId}'); - return song; - } - else - { - trace('[SONGDATA] Failed to fetch song, not found in cache: ${songId}'); - return null; - } - } + /** + * Retrieves a particular song from the cache. + */ + public static function fetchSong(songId:String):Null + { + if (songCache.exists(songId)) + { + var song:Song = songCache.get(songId); + trace('Successfully fetch song: ${songId}'); + return song; + } + else + { + trace('Failed to fetch song, not found in cache: ${songId}'); + return null; + } + } - static function clearSongCache():Void - { - if (songCache != null) - { - songCache.clear(); - } - } + static function clearSongCache():Void + { + if (songCache != null) + { + songCache.clear(); + } + } - public static function listSongIds():Array - { - return songCache.keys().array(); - } + public static function listSongIds():Array + { + return songCache.keys().array(); + } - public static function parseSongMetadata(songId:String):Array - { - var result:Array = []; + public static function parseSongMetadata(songId:String):Array + { + var result:Array = []; - var rawJson:String = loadSongMetadataFile(songId); - var jsonData:Dynamic = null; - try - { - jsonData = Json.parse(rawJson); - } - catch (e) - { - } + var rawJson:String = loadSongMetadataFile(songId); + var jsonData:Dynamic = null; + try + { + jsonData = Json.parse(rawJson); + } + catch (e) {} - var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId); - songMetadata = SongValidator.validateSongMetadata(songMetadata, songId); + var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId); + songMetadata = SongValidator.validateSongMetadata(songMetadata, songId); - if (songMetadata == null) - { - return result; - } + if (songMetadata == null) + { + return result; + } - result.push(songMetadata); + result.push(songMetadata); - var variations = songMetadata.playData.songVariations; + var variations = songMetadata.playData.songVariations; - for (variation in variations) - { - var variationRawJson:String = loadSongMetadataFile(songId, variation); - var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationRawJson, '${songId}_${variation}'); - variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}_${variation}'); - if (variationSongMetadata != null) - { - variationSongMetadata.variation = variation; - result.push(variationSongMetadata); - } - } + for (variation in variations) + { + var variationRawJson:String = loadSongMetadataFile(songId, variation); + var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationRawJson, '${songId}_${variation}'); + variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}_${variation}'); + if (variationSongMetadata != null) + { + variationSongMetadata.variation = variation; + result.push(variationSongMetadata); + } + } - return result; - } + return result; + } - static function loadSongMetadataFile(songPath:String, variation:String = ''):String - { - var songMetadataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata'); + static function loadSongMetadataFile(songPath:String, variation:String = ''):String + { + var songMetadataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata'); - var rawJson:String = Assets.getText(songMetadataFilePath).trim(); + var rawJson:String = Assets.getText(songMetadataFilePath).trim(); - while (!rawJson.endsWith("}")) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } + while (!rawJson.endsWith("}")) + { + rawJson = rawJson.substr(0, rawJson.length - 1); + } - return rawJson; - } + return rawJson; + } - public static function parseSongChartData(songId:String, variation:String = ""):SongChartData - { - var rawJson:String = loadSongChartDataFile(songId, variation); - var jsonData:Dynamic = null; - try - { - jsonData = Json.parse(rawJson); - } - catch (e) - { - } + public static function parseSongChartData(songId:String, variation:String = ""):SongChartData + { + var rawJson:String = loadSongChartDataFile(songId, variation); + var jsonData:Dynamic = null; + try + { + jsonData = Json.parse(rawJson); + } + catch (e) {} - var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId); - songChartData = SongValidator.validateSongChartData(songChartData, songId); + var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId); + songChartData = SongValidator.validateSongChartData(songChartData, songId); - if (songChartData == null) - { - trace('Failed to validate song chart data: ${songId}'); - return null; - } + if (songChartData == null) + { + trace('Failed to validate song chart data: ${songId}'); + return null; + } - return songChartData; - } + return songChartData; + } - static function loadSongChartDataFile(songPath:String, variation:String = ''):String - { - var songChartDataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart'); + static function loadSongChartDataFile(songPath:String, variation:String = ''):String + { + var songChartDataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart'); - var rawJson:String = Assets.getText(songChartDataFilePath).trim(); + var rawJson:String = Assets.getText(songChartDataFilePath).trim(); - while (!rawJson.endsWith("}")) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } + while (!rawJson.endsWith("}")) + { + rawJson = rawJson.substr(0, rawJson.length - 1); + } - return rawJson; - } + return rawJson; + } } typedef RawSongMetadata = { - /** - * A semantic versioning string for the song data format. - * - */ - var version:Version; + /** + * A semantic versioning string for the song data format. + * + */ + var version:Version; - var songName:String; - var artist:String; - var timeFormat:SongTimeFormat; - var divisions:Int; - var timeChanges:Array; - var loop:Bool; - var playData:SongPlayData; - var generatedBy:String; + var songName:String; + var artist:String; + var timeFormat:SongTimeFormat; + var divisions:Int; + var timeChanges:Array; + var loop:Bool; + var playData:SongPlayData; + var generatedBy:String; - /** - * Defaults to `default` or `''`. Populated later. - */ - var variation:String; + /** + * Defaults to `default` or `''`. Populated later. + */ + var variation:String; }; @:forward abstract SongMetadata(RawSongMetadata) { - public function new(songName:String, artist:String, variation:String = 'default') - { - this = { - version: SongMigrator.CHART_VERSION, - songName: songName, - artist: artist, - timeFormat: 'ms', - divisions: 96, - timeChanges: [new SongTimeChange(-1, 0, 100, 4, 4, [4, 4, 4, 4])], - loop: false, - playData: { - songVariations: [], - difficulties: ['normal'], + public function new(songName:String, artist:String, variation:String = 'default') + { + this = { + version: SongMigrator.CHART_VERSION, + songName: songName, + artist: artist, + timeFormat: 'ms', + divisions: 96, + timeChanges: [new SongTimeChange(-1, 0, 100, 4, 4, [4, 4, 4, 4])], + loop: false, + playData: { + songVariations: [], + difficulties: ['normal'], - playableChars: { - bf: new SongPlayableChar('gf', 'dad'), - }, + playableChars: { + bf: new SongPlayableChar('gf', 'dad'), + }, - stage: 'mainStage', - noteSkin: 'Normal' - }, - generatedBy: SongValidator.DEFAULT_GENERATEDBY, + stage: 'mainStage', + noteSkin: 'Normal' + }, + generatedBy: SongValidator.DEFAULT_GENERATEDBY, - // Variation ID. - variation: variation - }; - } + // Variation ID. + variation: variation + }; + } - public function clone(?newVariation:String = null):SongMetadata - { - var result = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); - result.version = this.version; - result.timeFormat = this.timeFormat; - result.divisions = this.divisions; - result.timeChanges = this.timeChanges; - result.loop = this.loop; - result.playData = this.playData; - result.generatedBy = this.generatedBy; + public function clone(?newVariation:String = null):SongMetadata + { + var result = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.timeChanges = this.timeChanges; + result.loop = this.loop; + result.playData = this.playData; + result.generatedBy = this.generatedBy; - return result; - } + return result; + } } typedef SongPlayData = { - var songVariations:Array; - var difficulties:Array; + var songVariations:Array; + var difficulties:Array; - /** - * Keys are the player characters and the values give info on what opponent/GF/inst to use. - */ - var playableChars:DynamicAccess; + /** + * Keys are the player characters and the values give info on what opponent/GF/inst to use. + */ + var playableChars:DynamicAccess; - var stage:String; - var noteSkin:String; + var stage:String; + var noteSkin:String; } typedef RawSongPlayableChar = { - var g:String; - var o:String; - var i:String; + var g:String; + var o:String; + var i:String; } typedef RawSongNoteData = { - /** - * The timestamp of the note. The timestamp is in the format of the song's time format. - */ - var t:Float; + /** + * The timestamp of the note. The timestamp is in the format of the song's time format. + */ + var t:Float; - /** - * Data for the note. Represents the index on the strumline. - * 0 = left, 1 = down, 2 = up, 3 = right - * `floor(direction / strumlineSize)` specifies which strumline the note is on. - * 0 = player, 1 = opponent, etc. - */ - var d:Int; + /** + * Data for the note. Represents the index on the strumline. + * 0 = left, 1 = down, 2 = up, 3 = right + * `floor(direction / strumlineSize)` specifies which strumline the note is on. + * 0 = player, 1 = opponent, etc. + */ + var d:Int; - /** - * Length of the note, if applicable. - * Defaults to 0 for single notes. - */ - var l:Float; + /** + * Length of the note, if applicable. + * Defaults to 0 for single notes. + */ + var l:Float; - /** - * The kind of the note. - * This can allow the note to include information used for custom behavior. - * Defaults to blank or `"normal"`. - */ - var k:String; + /** + * The kind of the note. + * This can allow the note to include information used for custom behavior. + * Defaults to blank or `"normal"`. + */ + var k:String; } abstract SongNoteData(RawSongNoteData) { - public function new(time:Float, data:Int, length:Float = 0, kind:String = "") - { - this = { - t: time, - d: data, - l: length, - k: kind - }; - } + public function new(time:Float, data:Int, length:Float = 0, kind:String = "") + { + this = { + t: time, + d: data, + l: length, + k: kind + }; + } - public var time(get, set):Float; + public var time(get, set):Float; - public function get_time():Float - { - return this.t; - } + public function get_time():Float + { + return this.t; + } - public function set_time(value:Float):Float - { - return this.t = value; - } + public function set_time(value:Float):Float + { + return this.t = value; + } - public var stepTime(get, never):Float; + public var stepTime(get, never):Float; - public function get_stepTime():Float - { - // TODO: Account for changes in BPM. - return this.t / Conductor.stepCrochet; - } + public function get_stepTime():Float + { + // TODO: Account for changes in BPM. + return this.t / Conductor.stepCrochet; + } - /** - * The raw data for the note. - */ - public var data(get, set):Int; + /** + * The raw data for the note. + */ + public var data(get, set):Int; - public function get_data():Int - { - return this.d; - } + public function get_data():Int + { + return this.d; + } - public function set_data(value:Int):Int - { - return this.d = value; - } + public function set_data(value:Int):Int + { + return this.d = value; + } - /** - * The direction of the note, if applicable. - * Strips the strumline index from the data. - * - * 0 = left, 1 = down, 2 = up, 3 = right - */ - public inline function getDirection(strumlineSize:Int = 4):Int - { - return this.d % strumlineSize; - } + /** + * The direction of the note, if applicable. + * Strips the strumline index from the data. + * + * 0 = left, 1 = down, 2 = up, 3 = right + */ + public inline function getDirection(strumlineSize:Int = 4):Int + { + return this.d % strumlineSize; + } - public function getDirectionName(strumlineSize:Int = 4):String - { - switch (this.d % strumlineSize) - { - case 0: - return 'Left'; - case 1: - return 'Down'; - case 2: - return 'Up'; - case 3: - return 'Right'; - default: - return 'Unknown'; - } - } + public function getDirectionName(strumlineSize:Int = 4):String + { + switch (this.d % strumlineSize) + { + case 0: + return 'Left'; + case 1: + return 'Down'; + case 2: + return 'Up'; + case 3: + return 'Right'; + default: + return 'Unknown'; + } + } - /** - * The strumline index of the note, if applicable. - * Strips the direction from the data. - * - * 0 = player, 1 = opponent, etc. - */ - public inline function getStrumlineIndex(strumlineSize:Int = 4):Int - { - return Math.floor(this.d / strumlineSize); - } + /** + * The strumline index of the note, if applicable. + * Strips the direction from the data. + * + * 0 = player, 1 = opponent, etc. + */ + public inline function getStrumlineIndex(strumlineSize:Int = 4):Int + { + return Math.floor(this.d / strumlineSize); + } - public inline function getMustHitNote(strumlineSize:Int = 4):Bool - { - return getStrumlineIndex(strumlineSize) == 0; - } + public inline function getMustHitNote(strumlineSize:Int = 4):Bool + { + return getStrumlineIndex(strumlineSize) == 0; + } - public var length(get, set):Float; + public var length(get, set):Float; - public function get_length():Float - { - return this.l; - } + public function get_length():Float + { + return this.l; + } - public function set_length(value:Float):Float - { - return this.l = value; - } + public function set_length(value:Float):Float + { + return this.l = value; + } - public var kind(get, set):String; + public var kind(get, set):String; - public function get_kind():String - { - if (this.k == null || this.k == '') - return 'normal'; + public function get_kind():String + { + if (this.k == null || this.k == '') + return 'normal'; - return this.k; - } + return this.k; + } - public function set_kind(value:String):String - { - if (value == 'normal' || value == '') - value = null; - return this.k = value; - } + public function set_kind(value:String):String + { + if (value == 'normal' || value == '') + value = null; + return this.k = value; + } - @:op(A == B) - public function op_equals(other:SongNoteData):Bool - { - if (this.k == '') - if (other.kind != '' && other.kind != 'normal') - return false; + @:op(A == B) + public function op_equals(other:SongNoteData):Bool + { + if (this.k == '') + if (other.kind != '' && other.kind != 'normal') + return false; - return this.t == other.time && this.d == other.data && this.l == other.length; - } + return this.t == other.time && this.d == other.data && this.l == other.length; + } - @:op(A != B) - public function op_notEquals(other:SongNoteData):Bool - { - return this.t != other.time || this.d != other.data || this.l != other.length || this.k != other.kind; - } + @:op(A != B) + public function op_notEquals(other:SongNoteData):Bool + { + return this.t != other.time || this.d != other.data || this.l != other.length || this.k != other.kind; + } - @:op(A > B) - public function op_greaterThan(other:SongNoteData):Bool - { - return this.t > other.time; - } + @:op(A > B) + public function op_greaterThan(other:SongNoteData):Bool + { + return this.t > other.time; + } - @:op(A < B) - public function op_lessThan(other:SongNoteData):Bool - { - return this.t < other.time; - } + @:op(A < B) + public function op_lessThan(other:SongNoteData):Bool + { + return this.t < other.time; + } - @:op(A >= B) - public function op_greaterThanOrEquals(other:SongNoteData):Bool - { - return this.t >= other.time; - } + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongNoteData):Bool + { + return this.t >= other.time; + } - @:op(A <= B) - public function op_lessThanOrEquals(other:SongNoteData):Bool - { - return this.t <= other.time; - } + @:op(A <= B) + public function op_lessThanOrEquals(other:SongNoteData):Bool + { + return this.t <= other.time; + } } typedef RawSongEventData = { - /** - * The timestamp of the event. The timestamp is in the format of the song's time format. - */ - var t:Float; + /** + * The timestamp of the event. The timestamp is in the format of the song's time format. + */ + var t:Float; - /** - * The kind of the event. - * Examples include "FocusCamera" and "PlayAnimation" - * Custom events can be added by scripts with the `ScriptedSongEvent` class. - */ - var e:String; + /** + * The kind of the event. + * Examples include "FocusCamera" and "PlayAnimation" + * Custom events can be added by scripts with the `ScriptedSongEvent` class. + */ + var e:String; - /** - * The data for the event. - * This can allow the event to include information used for custom behavior. - * Data type depends on the event kind. It can be anything that's JSON serializable. - */ - var v:Dynamic; + /** + * The data for the event. + * This can allow the event to include information used for custom behavior. + * Data type depends on the event kind. It can be anything that's JSON serializable. + */ + var v:DynamicAccess; + + /** + * Whether this event has been activated. + * This is only used internally by the game. It should not be serialized. + */ + @:optional var a:Bool; } abstract SongEventData(RawSongEventData) { - public function new(time:Float, event:String, value:Dynamic = null) - { - this = { - t: time, - e: event, - v: value - }; - } + public function new(time:Float, event:String, value:Dynamic = null) + { + this = { + t: time, + e: event, + v: value, + a: false + }; + } - public var time(get, set):Float; + public var time(get, set):Float; - public function get_time():Float - { - return this.t; - } + public function get_time():Float + { + return this.t; + } - public function set_time(value:Float):Float - { - return this.t = value; - } + public function set_time(value:Float):Float + { + return this.t = value; + } - public var event(get, set):String; + public var stepTime(get, never):Float; - public function get_event():String - { - return this.e; - } + public function get_stepTime():Float + { + // TODO: Account for changes in BPM. + return this.t / Conductor.stepCrochet; + } - public function set_event(value:String):String - { - return this.e = value; - } + public var event(get, set):String; - public var value(get, set):Dynamic; + public function get_event():String + { + return this.e; + } - public function get_value():Dynamic - { - return this.v; - } + public function set_event(value:String):String + { + return this.e = value; + } - public function set_value(value:Dynamic):Dynamic - { - return this.v = value; - } + public var value(get, set):Dynamic; - public inline function getBool():Bool - { - return cast this.v; - } + public function get_value():Dynamic + { + return this.v; + } - public inline function getInt():Int - { - return cast this.v; - } + public function set_value(value:Dynamic):Dynamic + { + return this.v = value; + } - public inline function getFloat():Float - { - return cast this.v; - } + public var activated(get, set):Bool; - public inline function getString():String - { - return cast this.v; - } + public function get_activated():Bool + { + return this.a; + } - public inline function getArray():Array - { - return cast this.v; - } + public function set_activated(value:Bool):Bool + { + return this.a = value; + } - public inline function getBoolArray():Array - { - return cast this.v; - } + public inline function getDynamic(key:String):Null + { + return this.v.get(key); + } - @:op(A == B) - public function op_equals(other:SongEventData):Bool - { - return this.t == other.time && this.e == other.event && this.v == other.value; - } + public inline function getBool(key:String):Null + { + return cast this.v.get(key); + } - @:op(A != B) - public function op_notEquals(other:SongEventData):Bool - { - return this.t != other.time || this.e != other.event || this.v != other.value; - } + public inline function getInt(key:String):Null + { + return cast this.v.get(key); + } - @:op(A > B) - public function op_greaterThan(other:SongEventData):Bool - { - return this.t > other.time; - } + public inline function getFloat(key:String):Null + { + return cast this.v.get(key); + } - @:op(A < B) - public function op_lessThan(other:SongEventData):Bool - { - return this.t < other.time; - } + public inline function getString(key:String):String + { + return cast this.v.get(key); + } - @:op(A >= B) - public function op_greaterThanOrEquals(other:SongEventData):Bool - { - return this.t >= other.time; - } + public inline function getArray(key:String):Array + { + return cast this.v.get(key); + } - @:op(A <= B) - public function op_lessThanOrEquals(other:SongEventData):Bool - { - return this.t <= other.time; - } + public inline function getBoolArray(key:String):Array + { + return cast this.v.get(key); + } + + @:op(A == B) + public function op_equals(other:SongEventData):Bool + { + return this.t == other.time && this.e == other.event && this.v == other.value; + } + + @:op(A != B) + public function op_notEquals(other:SongEventData):Bool + { + return this.t != other.time || this.e != other.event || this.v != other.value; + } + + @:op(A > B) + public function op_greaterThan(other:SongEventData):Bool + { + return this.t > other.time; + } + + @:op(A < B) + public function op_lessThan(other:SongEventData):Bool + { + return this.t < other.time; + } + + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongEventData):Bool + { + return this.t >= other.time; + } + + @:op(A <= B) + public function op_lessThanOrEquals(other:SongEventData):Bool + { + return this.t <= other.time; + } } abstract SongPlayableChar(RawSongPlayableChar) { - public function new(girlfriend:String, opponent:String, inst:String = "") - { - this = { - g: girlfriend, - o: opponent, - i: inst - }; - } + public function new(girlfriend:String, opponent:String, inst:String = "") + { + this = { + g: girlfriend, + o: opponent, + i: inst + }; + } - public var girlfriend(get, set):String; + public var girlfriend(get, set):String; - public function get_girlfriend():String - { - return this.g; - } + public function get_girlfriend():String + { + return this.g; + } - public function set_girlfriend(value:String):String - { - return this.g = value; - } + public function set_girlfriend(value:String):String + { + return this.g = value; + } - public var opponent(get, set):String; + public var opponent(get, set):String; - public function get_opponent():String - { - return this.o; - } + public function get_opponent():String + { + return this.o; + } - public function set_opponent(value:String):String - { - return this.o = value; - } + public function set_opponent(value:String):String + { + return this.o = value; + } - public var inst(get, set):String; + public var inst(get, set):String; - public function get_inst():String - { - return this.i; - } + public function get_inst():String + { + return this.i; + } - public function set_inst(value:String):String - { - return this.i = value; - } + public function set_inst(value:String):String + { + return this.i = value; + } } typedef RawSongChartData = { - var version:Version; + var version:Version; - var scrollSpeed:DynamicAccess; - var events:Array; - var notes:DynamicAccess>; - var generatedBy:String; + var scrollSpeed:DynamicAccess; + var events:Array; + var notes:DynamicAccess>; + var generatedBy:String; }; @:forward abstract SongChartData(RawSongChartData) { - public function new(scrollSpeed:Float, events:Array, notes:Array) - { - this = { - version: SongMigrator.CHART_VERSION, + public function new(scrollSpeed:Float, events:Array, notes:Array) + { + this = { + version: SongMigrator.CHART_VERSION, - events: events, - notes: { - normal: notes - }, - scrollSpeed: { - normal: scrollSpeed - }, - generatedBy: SongValidator.DEFAULT_GENERATEDBY - } - } + events: events, + notes: { + normal: notes + }, + scrollSpeed: { + normal: scrollSpeed + }, + generatedBy: SongValidator.DEFAULT_GENERATEDBY + } + } - public function getScrollSpeed(diff:String = 'default'):Float - { - var result:Float = this.scrollSpeed.get(diff); + public function getScrollSpeed(diff:String = 'default'):Float + { + var result:Float = this.scrollSpeed.get(diff); - if (result == 0.0 && diff != 'default') - return getScrollSpeed('default'); + if (result == 0.0 && diff != 'default') + return getScrollSpeed('default'); - return (result == 0.0) ? 1.0 : result; - } + return (result == 0.0) ? 1.0 : result; + } } typedef RawSongTimeChange = { - /** - * Timestamp in specified `timeFormat`. - */ - var t:Float; + /** + * Timestamp in specified `timeFormat`. + */ + var t:Float; - /** - * Time in beats (int). The game will calculate further beat values based on this one, - * so it can do it in a simple linear fashion. - */ - var b:Int; + /** + * Time in beats (int). The game will calculate further beat values based on this one, + * so it can do it in a simple linear fashion. + */ + var b:Int; - /** - * Quarter notes per minute (float). Cannot be empty in the first element of the list, - * but otherwise it's optional, and defaults to the value of the previous element. - */ - var bpm:Float; + /** + * Quarter notes per minute (float). Cannot be empty in the first element of the list, + * but otherwise it's optional, and defaults to the value of the previous element. + */ + var bpm:Float; - /** - * Time signature numerator (int). Optional, defaults to 4. - */ - var n:Int; + /** + * Time signature numerator (int). Optional, defaults to 4. + */ + var n:Int; - /** - * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. - */ - var d:Int; + /** + * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. + */ + var d:Int; - /** - * Beat tuplets (Array or int). This defines how many steps each beat is divided into. - * It can either be an array of length `n` (see above) or a single integer number. - * Optional, defaults to `[4]`. - */ - var bt:OneOfTwo>; + /** + * Beat tuplets (Array or int). This defines how many steps each beat is divided into. + * It can either be an array of length `n` (see above) or a single integer number. + * Optional, defaults to `[4]`. + */ + var bt:OneOfTwo>; } /** @@ -758,101 +786,101 @@ typedef RawSongTimeChange = */ abstract SongTimeChange(RawSongTimeChange) { - public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array) - { - this = { - t: timeStamp, - b: beatTime, - bpm: bpm, - n: timeSignatureNum, - d: timeSignatureDen, - bt: beatTuplets, - } - } + public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array) + { + this = { + t: timeStamp, + b: beatTime, + bpm: bpm, + n: timeSignatureNum, + d: timeSignatureDen, + bt: beatTuplets, + } + } - public var timeStamp(get, set):Float; + public var timeStamp(get, set):Float; - public function get_timeStamp():Float - { - return this.t; - } + public function get_timeStamp():Float + { + return this.t; + } - public function set_timeStamp(value:Float):Float - { - return this.t = value; - } + public function set_timeStamp(value:Float):Float + { + return this.t = value; + } - public var beatTime(get, set):Int; + public var beatTime(get, set):Int; - public function get_beatTime():Int - { - return this.b; - } + public function get_beatTime():Int + { + return this.b; + } - public function set_beatTime(value:Int):Int - { - return this.b = value; - } + public function set_beatTime(value:Int):Int + { + return this.b = value; + } - public var bpm(get, set):Float; + public var bpm(get, set):Float; - public function get_bpm():Float - { - return this.bpm; - } + public function get_bpm():Float + { + return this.bpm; + } - public function set_bpm(value:Float):Float - { - return this.bpm = value; - } + public function set_bpm(value:Float):Float + { + return this.bpm = value; + } - public var timeSignatureNum(get, set):Int; + public var timeSignatureNum(get, set):Int; - public function get_timeSignatureNum():Int - { - return this.n; - } + public function get_timeSignatureNum():Int + { + return this.n; + } - public function set_timeSignatureNum(value:Int):Int - { - return this.n = value; - } + public function set_timeSignatureNum(value:Int):Int + { + return this.n = value; + } - public var timeSignatureDen(get, set):Int; + public var timeSignatureDen(get, set):Int; - public function get_timeSignatureDen():Int - { - return this.d; - } + public function get_timeSignatureDen():Int + { + return this.d; + } - public function set_timeSignatureDen(value:Int):Int - { - return this.d = value; - } + public function set_timeSignatureDen(value:Int):Int + { + return this.d = value; + } - public var beatTuplets(get, set):Array; + public var beatTuplets(get, set):Array; - public function get_beatTuplets():Array - { - if (Std.isOfType(this.bt, Int)) - { - return [this.bt]; - } - else - { - return this.bt; - } - } + public function get_beatTuplets():Array + { + if (Std.isOfType(this.bt, Int)) + { + return [this.bt]; + } + else + { + return this.bt; + } + } - public function set_beatTuplets(value:Array):Array - { - return this.bt = value; - } + public function set_beatTuplets(value:Array):Array + { + return this.bt = value; + } } enum abstract SongTimeFormat(String) from String to String { - var TICKS = "ticks"; - var FLOAT = "float"; - var MILLISECONDS = "ms"; + var TICKS = "ticks"; + var FLOAT = "float"; + var MILLISECONDS = "ms"; } diff --git a/source/funkin/play/song/SongDataUtils.hx b/source/funkin/play/song/SongDataUtils.hx index 0c03c1e36..834aaa64f 100644 --- a/source/funkin/play/song/SongDataUtils.hx +++ b/source/funkin/play/song/SongDataUtils.hx @@ -26,6 +26,22 @@ class SongDataUtils }); } + /** + * Given an array of SongEventData objects, return a new array of SongEventData objects + * whose timestamps are shifted by the given amount. + * Does not mutate the original array. + * + * @param events The events to modify. + * @param offset The time difference to apply in milliseconds. + */ + public static function offsetSongEventData(events:Array, offset:Int):Array + { + return events.map(function(event:SongEventData):SongEventData + { + return new SongEventData(event.time + offset, event.event, event.value); + }); + } + /** * Return a new array without a certain subset of notes from an array of SongNoteData objects. * Does not mutate the original array. @@ -94,11 +110,21 @@ class SongDataUtils * * Offset the provided array of notes such that the first note is at 0 milliseconds. */ - public static function buildClipboard(notes:Array):Array + public static function buildNoteClipboard(notes:Array):Array { return offsetSongNoteData(sortNotes(notes), -Std.int(notes[0].time)); } + /** + * Prepare an array of events to be used as the clipboard data. + * + * Offset the provided array of events such that the first event is at 0 milliseconds. + */ + public static function buildEventClipboard(events:Array):Array + { + return offsetSongEventData(sortEvents(events), -Std.int(events[0].time)); + } + /** * Sort an array of notes by strum time. */ @@ -113,39 +139,55 @@ class SongDataUtils } /** - * Serialize an array of note data and write it to the clipboard. + * Sort an array of events by strum time. */ - public static function writeNotesToClipboard(notes:Array):Void + public static function sortEvents(events:Array, ?desc:Bool = false):Array { - var notesString = SerializerUtil.toJSON(notes); + // TODO: Modifies the array in place. Is this okay? + events.sort(function(a:SongEventData, b:SongEventData):Int + { + return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time); + }); + return events; + } - ClipboardUtil.setClipboard(notesString); + /** + * Serialize note and event data and write it to the clipboard. + */ + public static function writeItemsToClipboard(data:SongClipboardItems):Void + { + var dataString = SerializerUtil.toJSON(data); - trace('Wrote ' + notes.length + ' notes to clipboard.'); + ClipboardUtil.setClipboard(dataString); - trace(notesString); + trace('Wrote ' + data.notes.length + ' notes and ' + data.events.length + ' events to clipboard.'); + + trace(dataString); } /** * Read an array of note data from the clipboard and deserialize it. */ - public static function readNotesFromClipboard():Array + public static function readItemsFromClipboard():SongClipboardItems { var notesString = ClipboardUtil.getClipboard(); - trace('Read ' + notesString.length + ' characters from clipboard.'); + trace('Read ${notesString.length} characters from clipboard.'); - var notes:Array = notesString.parseJSON(); + var data:SongClipboardItems = notesString.parseJSON(); - if (notes == null) + if (data == null) { trace('Failed to parse notes from clipboard.'); - return []; + return { + notes: [], + events: [] + }; } else { - trace('Parsed ' + notes.length + ' notes from clipboard.'); - return notes; + trace('Parsed ' + data.notes.length + ' notes and ' + data.events.length + ' from clipboard.'); + return data; } } @@ -160,6 +202,17 @@ class SongDataUtils }); } + /** + * Filter a list of events to only include events that are within the given time range. + */ + public static function getEventsInTimeRange(events:Array, start:Float, end:Float):Array + { + return events.filter(function(event:SongEventData):Bool + { + return event.time >= start && event.time <= end; + }); + } + /** * Filter a list of notes to only include notes whose data is within the given range. */ @@ -182,3 +235,9 @@ class SongDataUtils }); } } + +typedef SongClipboardItems = +{ + notes:Array, + events:Array +} diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx index 9199d7d14..1872585d0 100644 --- a/source/funkin/play/song/SongMigrator.hx +++ b/source/funkin/play/song/SongMigrator.hx @@ -6,74 +6,74 @@ import funkin.util.VersionUtil; class SongMigrator { - /** - * The current latest version string for the song data format. - * Handle breaking changes by incrementing this value - * and adding migration to the SongMigrator class. - */ - public static final CHART_VERSION:String = "2.0.0"; + /** + * The current latest version string for the song data format. + * Handle breaking changes by incrementing this value + * and adding migration to the SongMigrator class. + */ + public static final CHART_VERSION:String = "2.0.0"; - public static final CHART_VERSION_RULE:String = "2.0.x"; + public static final CHART_VERSION_RULE:String = "2.0.x"; - public static function migrateSongMetadata(jsonData:Dynamic, songId:String):SongMetadata - { - if (jsonData.version) - { - if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE)) - { - trace('[SONGDATA] Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.'); + public static function migrateSongMetadata(jsonData:Dynamic, songId:String):SongMetadata + { + if (jsonData.version) + { + if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE)) + { + trace('Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.'); - var songMetadata:SongMetadata = cast jsonData; + var songMetadata:SongMetadata = cast jsonData; - return songMetadata; - } - else - { - trace('[SONGDATA] Song (${songId}) metadata version (${jsonData.version}) is outdated.'); - switch (jsonData.version) - { - // TODO: Add migration functions as cases here. - default: - // Unknown version. - trace('[SONGDATA] Song (${songId}) unknown metadata version: ${jsonData.version}'); - } - } - } - else - { - trace('[SONGDATA] Song metadata version is missing.'); - } - return null; - } + return songMetadata; + } + else + { + trace('Song (${songId}) metadata version (${jsonData.version}) is outdated.'); + switch (jsonData.version) + { + // TODO: Add migration functions as cases here. + default: + // Unknown version. + trace('Song (${songId}) unknown metadata version: ${jsonData.version}'); + } + } + } + else + { + trace('Song metadata version is missing.'); + } + return null; + } - public static function migrateSongChartData(jsonData:Dynamic, songId:String):SongChartData - { - if (jsonData.version) - { - if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE)) - { - trace('[SONGDATA] Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.'); + public static function migrateSongChartData(jsonData:Dynamic, songId:String):SongChartData + { + if (jsonData.version) + { + if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE)) + { + trace('Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.'); - var songChartData:SongChartData = cast jsonData; + var songChartData:SongChartData = cast jsonData; - return songChartData; - } - else - { - trace('[SONGDATA] Song (${songId}) chart version (${jsonData.version}) is outdated.'); - switch (jsonData.version) - { - // TODO: Add migration functions as cases here. - default: - // Unknown version. - trace('[SONGDATA] Song (${songId}) unknown chart version: ${jsonData.version}'); - } - } - } - else - { - trace('[SONGDATA] Song chart version is missing.'); - } - return null; - } + return songChartData; + } + else + { + trace('Song (${songId}) chart version (${jsonData.version}) is outdated.'); + switch (jsonData.version) + { + // TODO: Add migration functions as cases here. + default: + // Unknown version. + trace('Song (${songId}) unknown chart version: ${jsonData.version}'); + } + } + } + else + { + trace('Song chart version is missing.'); + } + return null; + } } diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index 39e46bccf..298b38d43 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -15,393 +15,358 @@ typedef AnimationFinishedCallback = String->Void; */ class Bopper extends FlxSprite implements IPlayStateScriptedClass { - /** - * The bopper plays the dance animation once every `danceEvery` beats. - * Set to 0 to disable idle animation. - */ - public var danceEvery:Int = 1; - - /** - * Whether the bopper should dance left and right. - * - If true, alternate playing `danceLeft` and `danceRight`. - * - If false, play `idle` every time. - * - * You can manually set this value, or you can leave it as `null` to determine it automatically. - */ - public var shouldAlternate:Null = null; - - /** - * Offset the character's sprite by this much when playing each animation. - */ - public var animationOffsets:Map> = new Map>(); - - /** - * Add a suffix to the `idle` animation (or `danceLeft` and `danceRight` animations) - * that this bopper will play. - */ - public var idleSuffix(default, set):String = ""; - - /** - * Whether this bopper should bop every beat. By default it's true, but when used - * for characters/players, it should be false so it doesn't cut off their animations!!!!! - */ - public var shouldBop:Bool = true; - - function set_idleSuffix(value:String):String - { - this.idleSuffix = value; - this.dance(); - return value; - } - - /** - * The offset of the character relative to the position specified by the stage. - */ - public var globalOffsets(default, set):Array = [0, 0]; - - function set_globalOffsets(value:Array) - { - if (globalOffsets == null) - globalOffsets = [0, 0]; - if (globalOffsets == value) - return value; - - var xDiff = globalOffsets[0] - value[0]; - var yDiff = globalOffsets[1] - value[1]; - - this.x += xDiff; - this.y += yDiff; - return animOffsets = value; - - } - - private var animOffsets(default, set):Array = [0, 0]; - - public var originalPosition:FlxPoint = new FlxPoint(0, 0); - - function set_animOffsets(value:Array) - { - if (animOffsets == null) - animOffsets = [0, 0]; - if (animOffsets == value) - return value; - - var xDiff = animOffsets[0] - value[0]; - var yDiff = animOffsets[1] - value[1]; - - this.x += xDiff; - this.y += yDiff; - - return animOffsets = value; - } - - /** - * Whether to play `danceRight` next iteration. - * Only used when `shouldAlternate` is true. - */ - var hasDanced:Bool = false; - - public function new(danceEvery:Int = 1) - { - super(); - this.danceEvery = danceEvery; - - this.animation.callback = this.onAnimationFrame; - this.animation.finishCallback = this.onAnimationFinished; - } - - /** - * Called when an animation finishes. - * @param name The name of the animation that just finished. - */ - function onAnimationFinished(name:String) - { - // TODO: Can we make a system of like, animation priority or something? - if (!canPlayOtherAnims) - { - canPlayOtherAnims = true; - } - } - - /** - * Called when the current animation's frame changes. - * @param name The name of the current animation. - * @param frameNumber The number of the current frame. - * @param frameIndex The index of the current frame. - * - * For example, if an animation was defined as having the indexes [3, 0, 1, 2], - * then the first callback would have frameNumber = 0 and frameIndex = 3. - */ - function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1) - { - // Do nothing by default. - // This can be overridden by, for example, scripted characters, - // or by calling `animationFrame.add()`. - - // Try not to do anything expensive here, it runs many times a second. - } - - /** - * If this Bopper was defined by the stage, return the prop to its original position. - */ - public function resetPosition() - { - this.x = originalPosition.x + animOffsets[0]; - this.y = originalPosition.y + animOffsets[1]; - } - - function update_shouldAlternate():Void - { - if (hasAnimation('danceLeft')) - { - this.shouldAlternate = true; - } - } - - /** - * Called once every beat of the song. - */ - public function onBeatHit(event:SongTimeScriptEvent):Void - { - if (danceEvery > 0 && event.beat % danceEvery == 0) - { - dance(shouldBop); - } - } - - /** - * Called every `danceEvery` beats of the song. - */ - public function dance(forceRestart:Bool = false):Void - { - if (this.animation == null) - { - return; - } - - if (shouldAlternate == null) - { - update_shouldAlternate(); - } - - if (shouldAlternate) - { - if (hasDanced) - { - playAnimation('danceRight$idleSuffix', forceRestart); - } - else - { - playAnimation('danceLeft$idleSuffix', forceRestart); - } - hasDanced = !hasDanced; - } - else - { - playAnimation('idle$idleSuffix', forceRestart); - } - } - - public function hasAnimation(id:String):Bool - { - if (this.animation == null) - return false; - - return this.animation.getByName(id) != null; - } - - /** - * Ensure that a given animation exists before playing it. - * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play. - * @param name - */ - function correctAnimationName(name:String) - { - // If the animation exists, we're good. - if (hasAnimation(name)) - return name; - - trace('[BOPPER] Animation "$name" does not exist!'); - - // Attempt to strip a `-alt` suffix, if it exists. - if (name.lastIndexOf('-') != -1) - { - var correctName = name.substring(0, name.lastIndexOf('-')); - trace('[BOPPER] Attempting to fallback to "$correctName"'); - return correctAnimationName(correctName); - } - else - { - if (name != 'idle') - { - trace('[BOPPER] Attempting to fallback to "idle"'); - return correctAnimationName('idle'); - } - else - { - trace('[BOPPER] Failing animation playback.'); - return null; - } - } - } - - public var canPlayOtherAnims:Bool = true; - - /** - * @param name The name of the animation to play. - * @param restart Whether to restart the animation if it is already playing. - * @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing - */ - public function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void - { - if (!canPlayOtherAnims && !ignoreOther) - return; - - var correctName = correctAnimationName(name); - if (correctName == null) - return; - - this.animation.play(correctName, restart, false, 0); - - if (ignoreOther) - { - canPlayOtherAnims = false; - } - - applyAnimationOffsets(correctName); - } - - var forceAnimationTimer:FlxTimer = new FlxTimer(); - - /** - * @param name The animation to play. - * @param duration The duration in which other (non-forced) animations will be skipped, in seconds (NOT MILLISECONDS). - */ - public function forceAnimationForDuration(name:String, duration:Float):Void - { - // TODO: Might be nice to rework this function, maybe have a numbered priority system? - - if (this.animation == null) - return; - - var correctName = correctAnimationName(name); - if (correctName == null) - return; - - this.animation.play(correctName, false, false); - applyAnimationOffsets(correctName); - - canPlayOtherAnims = false; - forceAnimationTimer.start(duration, (timer) -> - { - canPlayOtherAnims = true; - }, 1); - } - - function applyAnimationOffsets(name:String) - { - var offsets = animationOffsets.get(name); - if (offsets != null) - { - this.animOffsets = [offsets[0] + globalOffsets[0], offsets[1] + globalOffsets[1]]; - } - else - { - this.animOffsets = globalOffsets; - } - } - - public function isAnimationFinished():Bool - { - return this.animation.finished; - } - - public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void - { - animationOffsets.set(name, [xOffset, yOffset]); - } - - /** - * Returns the name of the animation that is currently playing. - * If no animation is playing (usually this means the character 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 onScriptEvent(event:ScriptEvent) - { - } - - public function onCreate(event:ScriptEvent) - { - } - - public function onDestroy(event:ScriptEvent) - { - } - - public function onUpdate(event:UpdateScriptEvent) - { - } - - public function onPause(event:PauseScriptEvent) - { - } - - public function onResume(event:ScriptEvent) - { - } - - public function onSongStart(event:ScriptEvent) - { - } - - public function onSongEnd(event:ScriptEvent) - { - } - - public function onGameOver(event:ScriptEvent) - { - } - - public function onNoteHit(event:NoteScriptEvent) - { - } - - public function onNoteMiss(event:NoteScriptEvent) - { - } - - public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) - { - } - - public function onStepHit(event:SongTimeScriptEvent) - { - } - - public function onCountdownStart(event:CountdownScriptEvent) - { - } - - public function onCountdownStep(event:CountdownScriptEvent) - { - } - - public function onCountdownEnd(event:CountdownScriptEvent) - { - } - - public function onSongLoaded(event:SongLoadScriptEvent) - { - } - - public function onSongRetry(event:ScriptEvent) - { - } + /** + * The bopper plays the dance animation once every `danceEvery` beats. + * Set to 0 to disable idle animation. + */ + public var danceEvery:Int = 1; + + /** + * Whether the bopper should dance left and right. + * - If true, alternate playing `danceLeft` and `danceRight`. + * - If false, play `idle` every time. + * + * You can manually set this value, or you can leave it as `null` to determine it automatically. + */ + public var shouldAlternate:Null = null; + + /** + * Offset the character's sprite by this much when playing each animation. + */ + public var animationOffsets:Map> = new Map>(); + + /** + * Add a suffix to the `idle` animation (or `danceLeft` and `danceRight` animations) + * that this bopper will play. + */ + public var idleSuffix(default, set):String = ""; + + /** + * Whether this bopper should bop every beat. By default it's true, but when used + * for characters/players, it should be false so it doesn't cut off their animations!!!!! + */ + public var shouldBop:Bool = true; + + function set_idleSuffix(value:String):String + { + this.idleSuffix = value; + this.dance(); + return value; + } + + /** + * The offset of the character relative to the position specified by the stage. + */ + public var globalOffsets(default, set):Array = [0, 0]; + + function set_globalOffsets(value:Array) + { + if (globalOffsets == null) + globalOffsets = [0, 0]; + if (globalOffsets == value) + return value; + + var xDiff = globalOffsets[0] - value[0]; + var yDiff = globalOffsets[1] - value[1]; + + this.x += xDiff; + this.y += yDiff; + return animOffsets = value; + } + + private var animOffsets(default, set):Array = [0, 0]; + + public var originalPosition:FlxPoint = new FlxPoint(0, 0); + + function set_animOffsets(value:Array) + { + if (animOffsets == null) + animOffsets = [0, 0]; + if (animOffsets == value) + return value; + + var xDiff = animOffsets[0] - value[0]; + var yDiff = animOffsets[1] - value[1]; + + this.x += xDiff; + this.y += yDiff; + + return animOffsets = value; + } + + /** + * Whether to play `danceRight` next iteration. + * Only used when `shouldAlternate` is true. + */ + var hasDanced:Bool = false; + + public function new(danceEvery:Int = 1) + { + super(); + this.danceEvery = danceEvery; + + this.animation.callback = this.onAnimationFrame; + this.animation.finishCallback = this.onAnimationFinished; + } + + /** + * Called when an animation finishes. + * @param name The name of the animation that just finished. + */ + function onAnimationFinished(name:String) + { + // TODO: Can we make a system of like, animation priority or something? + if (!canPlayOtherAnims) + { + canPlayOtherAnims = true; + } + } + + /** + * Called when the current animation's frame changes. + * @param name The name of the current animation. + * @param frameNumber The number of the current frame. + * @param frameIndex The index of the current frame. + * + * For example, if an animation was defined as having the indexes [3, 0, 1, 2], + * then the first callback would have frameNumber = 0 and frameIndex = 3. + */ + function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1) + { + // Do nothing by default. + // This can be overridden by, for example, scripted characters, + // or by calling `animationFrame.add()`. + + // Try not to do anything expensive here, it runs many times a second. + } + + /** + * If this Bopper was defined by the stage, return the prop to its original position. + */ + public function resetPosition() + { + this.x = originalPosition.x + animOffsets[0]; + this.y = originalPosition.y + animOffsets[1]; + } + + function update_shouldAlternate():Void + { + if (hasAnimation('danceLeft')) + { + this.shouldAlternate = true; + } + } + + /** + * Called once every beat of the song. + */ + public function onBeatHit(event:SongTimeScriptEvent):Void + { + if (danceEvery > 0 && event.beat % danceEvery == 0) + { + dance(shouldBop); + } + } + + /** + * Called every `danceEvery` beats of the song. + */ + public function dance(forceRestart:Bool = false):Void + { + if (this.animation == null) + { + return; + } + + if (shouldAlternate == null) + { + update_shouldAlternate(); + } + + if (shouldAlternate) + { + if (hasDanced) + { + playAnimation('danceRight$idleSuffix', forceRestart); + } + else + { + playAnimation('danceLeft$idleSuffix', forceRestart); + } + hasDanced = !hasDanced; + } + else + { + playAnimation('idle$idleSuffix', forceRestart); + } + } + + public function hasAnimation(id:String):Bool + { + if (this.animation == null) + return false; + + return this.animation.getByName(id) != null; + } + + /** + * Ensure that a given animation exists before playing it. + * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play. + * @param name + */ + function correctAnimationName(name:String) + { + // If the animation exists, we're good. + if (hasAnimation(name)) + return name; + + trace('[BOPPER] Animation "$name" does not exist!'); + + // Attempt to strip a `-alt` suffix, if it exists. + if (name.lastIndexOf('-') != -1) + { + var correctName = name.substring(0, name.lastIndexOf('-')); + trace('[BOPPER] Attempting to fallback to "$correctName"'); + return correctAnimationName(correctName); + } + else + { + if (name != 'idle') + { + trace('[BOPPER] Attempting to fallback to "idle"'); + return correctAnimationName('idle'); + } + else + { + trace('[BOPPER] Failing animation playback.'); + return null; + } + } + } + + public var canPlayOtherAnims:Bool = true; + + /** + * @param name The name of the animation to play. + * @param restart Whether to restart the animation if it is already playing. + * @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing + */ + public function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void + { + if (!canPlayOtherAnims && !ignoreOther) + return; + + var correctName = correctAnimationName(name); + if (correctName == null) + return; + + this.animation.play(correctName, restart, false, 0); + + if (ignoreOther) + { + canPlayOtherAnims = false; + } + + applyAnimationOffsets(correctName); + } + + var forceAnimationTimer:FlxTimer = new FlxTimer(); + + /** + * @param name The animation to play. + * @param duration The duration in which other (non-forced) animations will be skipped, in seconds (NOT MILLISECONDS). + */ + public function forceAnimationForDuration(name:String, duration:Float):Void + { + // TODO: Might be nice to rework this function, maybe have a numbered priority system? + + if (this.animation == null) + return; + + var correctName = correctAnimationName(name); + if (correctName == null) + return; + + this.animation.play(correctName, false, false); + applyAnimationOffsets(correctName); + + canPlayOtherAnims = false; + forceAnimationTimer.start(duration, (timer) -> + { + canPlayOtherAnims = true; + }, 1); + } + + function applyAnimationOffsets(name:String) + { + var offsets = animationOffsets.get(name); + if (offsets != null) + { + this.animOffsets = [offsets[0] + globalOffsets[0], offsets[1] + globalOffsets[1]]; + } + else + { + this.animOffsets = globalOffsets; + } + } + + public function isAnimationFinished():Bool + { + return this.animation.finished; + } + + public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void + { + animationOffsets.set(name, [xOffset, yOffset]); + } + + /** + * Returns the name of the animation that is currently playing. + * If no animation is playing (usually this means the character 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 onScriptEvent(event:ScriptEvent) {} + + public function onCreate(event:ScriptEvent) {} + + public function onDestroy(event:ScriptEvent) {} + + public function onUpdate(event:UpdateScriptEvent) {} + + public function onPause(event:PauseScriptEvent) {} + + public function onResume(event:ScriptEvent) {} + + public function onSongStart(event:ScriptEvent) {} + + public function onSongEnd(event:ScriptEvent) {} + + public function onGameOver(event:ScriptEvent) {} + + public function onNoteHit(event:NoteScriptEvent) {} + + public function onNoteMiss(event:NoteScriptEvent) {} + + public function onSongEvent(event:SongEventScriptEvent) {} + + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + + public function onStepHit(event:SongTimeScriptEvent) {} + + public function onCountdownStart(event:CountdownScriptEvent) {} + + public function onCountdownStep(event:CountdownScriptEvent) {} + + public function onCountdownEnd(event:CountdownScriptEvent) {} + + public function onSongLoaded(event:SongLoadScriptEvent) {} + + public function onSongRetry(event:ScriptEvent) {} } diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index 55c609ac8..83790e49d 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -21,621 +21,592 @@ import funkin.util.assets.FlxAnimationUtil; */ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass { - public final stageId:String; - public final stageName:String; - - final _data:StageData; - - public var camZoom:Float = 1.0; - - var namedProps:Map = new Map(); - var characters:Map = new Map(); - var boppers:Array = new Array(); - - /** - * The Stage elements get initialized at the beginning of the game. - * They're used to cache the data needed to build the stage, - * then accessed and fleshed out when the stage needs to be built. - * - * @param stageId - */ - public function new(stageId:String) - { - super(); - - this.stageId = stageId; - _data = StageDataParser.parseStageData(this.stageId); - if (_data == null) - { - throw 'Could not find stage data for stageId: $stageId'; - } - else - { - this.stageName = _data.name; - } - } - - /** - * Called when the player is moving into the PlayState where the song will be played. - */ - public function onCreate(event:ScriptEvent):Void - { - buildStage(); - this.refresh(); - - debugIconGroup = new FlxSpriteGroup(); - debugIconGroup.visible = false; - debugIconGroup.zIndex = 1000000; - add(debugIconGroup); - } - - public function resetStage():Void - { - // Reset positions of characters. - if (getBoyfriend() != null) - { - getBoyfriend().resetCharacter(false); - } - else - { - trace('STAGE RESET: No boyfriend found.'); - } - if (getGirlfriend() != null) - { - getGirlfriend().resetCharacter(false); - } - if (getDad() != null) - { - getDad().resetCharacter(false); - } - - // Reset positions of named props. - for (dataProp in _data.props) - { - // Fetch the prop. - var prop:FlxSprite = getNamedProp(dataProp.name); - - if (prop != null) - { - // Reset the position. - prop.x = dataProp.position[0]; - prop.y = dataProp.position[1]; - prop.zIndex = dataProp.zIndex; - } - } - - // We can assume unnamed props are not moving. - } - - /** - * The default stage construction routine. Called when the stage is going to be played in. - * Instantiates each prop and adds it to the stage, while setting its parameters. - */ - function buildStage() - { - trace('Building stage for display: ${this.stageId}'); - - this.camZoom = _data.cameraZoom; - - this.debugIconGroup = new FlxSpriteGroup(); - - for (dataProp in _data.props) - { - trace(' Placing prop: ${dataProp.name} (${dataProp.assetPath})'); - - var isAnimated = dataProp.animations.length > 0; - - var propSprite:FlxSprite; - if (dataProp.danceEvery != 0) - { - propSprite = new Bopper(dataProp.danceEvery); - } - else - { - propSprite = new FlxSprite(); - } - - if (isAnimated) - { - // Initalize sprite frames. - switch (dataProp.animType) - { - case "packer": - propSprite.frames = Paths.getPackerAtlas(dataProp.assetPath); - default: // "sparrow" - propSprite.frames = Paths.getSparrowAtlas(dataProp.assetPath); - } - } - else - { - // Initalize static sprite. - propSprite.loadGraphic(Paths.image(dataProp.assetPath)); - - // Disables calls to update() for a performance boost. - propSprite.active = false; - } - - if (propSprite.frames == null || propSprite.frames.numFrames == 0) - { - trace(' ERROR: Could not build texture for prop.'); - continue; - } - - if (Std.isOfType(dataProp.scale, Array)) - { - propSprite.scale.set(dataProp.scale[0], dataProp.scale[1]); - } - else - { - propSprite.scale.set(dataProp.scale); - } - propSprite.updateHitbox(); - - propSprite.x = dataProp.position[0]; - propSprite.y = dataProp.position[1]; - - propSprite.alpha = dataProp.alpha; - - // If pixel, disable antialiasing. - propSprite.antialiasing = !dataProp.isPixel; - - propSprite.scrollFactor.x = dataProp.scroll[0]; - propSprite.scrollFactor.y = dataProp.scroll[1]; - - propSprite.zIndex = dataProp.zIndex; - - switch (dataProp.animType) - { - case "packer": - for (propAnim in dataProp.animations) - { - propSprite.animation.add(propAnim.name, propAnim.frameIndices); - - if (Std.isOfType(propSprite, Bopper)) - { - cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); - } - } - default: // "sparrow" - FlxAnimationUtil.addAtlasAnimations(propSprite, dataProp.animations); - if (Std.isOfType(propSprite, Bopper)) - { - for (propAnim in dataProp.animations) - { - cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); - } - } - } - - if (Std.isOfType(propSprite, Bopper)) - { - for (propAnim in dataProp.animations) - { - cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); - } - cast(propSprite, Bopper).originalPosition.x = dataProp.position[0]; - cast(propSprite, Bopper).originalPosition.y = dataProp.position[1]; - } - - if (dataProp.startingAnimation != null) - { - propSprite.animation.play(dataProp.startingAnimation); - } - - if (Std.isOfType(propSprite, Bopper)) - { - addBopper(cast propSprite, dataProp.name); - } - else - { - addProp(propSprite, dataProp.name); - } - trace(' Prop placed.'); - } - } - - /** - * Add a sprite to the stage. - * @param prop The sprite to add. - * @param name (Optional) A unique name for the sprite. - * You can call `getNamedProp(name)` to retrieve it later. - */ - public function addProp(prop:FlxSprite, ?name:String = null) - { - if (name != null) - { - namedProps.set(name, prop); - } - this.add(prop); - } - - /** - * Add a sprite to the stage which animates to the beat of the song. - */ - public function addBopper(bopper:Bopper, ?name:String = null) - { - boppers.push(bopper); - this.addProp(bopper, name); - } - - /** - * Refreshes the stage, by redoing the render order of all props. - * It does this based on the `zIndex` of each prop. - */ - public function refresh() - { - sort(SortUtil.byZIndex, FlxSort.ASCENDING); - } - - public function setShader(shader:FlxShader) - { - forEachAlive(function(prop:FlxSprite) - { - prop.shader = shader; - }); - } - - /** - * Adjusts the position and other properties of the soon-to-be child of this sprite group. - * Private helper to avoid duplicate code in `add()` and `insert()`. - * - * @param Sprite The sprite or sprite group that is about to be added or inserted into the group. - */ - override function preAdd(Sprite:FlxSprite):Void - { - var sprite:FlxSprite = cast Sprite; - sprite.x += x; - sprite.y += y; - sprite.alpha *= alpha; - // Don't override scroll factors. - // sprite.scrollFactor.copyFrom(scrollFactor); - sprite.cameras = _cameras; // _cameras instead of cameras because get_cameras() will not return null - - if (clipRect != null) - clipRectTransform(sprite, clipRect); - } - - var debugIconGroup:FlxSpriteGroup; - - /** - * Used by the PlayState to add a character to the stage. - */ - public function addCharacter(character:BaseCharacter, charType:CharacterType) - { - if (character == null) - return; - - #if debug - // Temporary marker that shows where the character's location is relative to. - // Should display at the stage position of the character (before any offsets). - // TODO: Make this a toggle? It's useful to turn on from time to time. - var debugIcon:FlxSprite = new FlxSprite(0, 0); - var debugIcon2:FlxSprite = new FlxSprite(0, 0); - debugIcon.makeGraphic(8, 8, 0xffff00ff); - debugIcon2.makeGraphic(8, 8, 0xff00ffff); - debugIcon.visible = true; - debugIcon2.visible = true; - debugIcon.zIndex = 1000000; - debugIcon2.zIndex = 1000000; - #end - - // Apply position and z-index. - var charData:StageDataCharacter = null; - switch (charType) - { - case BF: - this.characters.set("bf", character); - charData = _data.characters.bf; - character.flipX = !character.getDataFlipX(); - character.initHealthIcon(false); - case GF: - this.characters.set("gf", character); - charData = _data.characters.gf; - character.flipX = character.getDataFlipX(); - case DAD: - this.characters.set("dad", character); - charData = _data.characters.dad; - character.flipX = character.getDataFlipX(); - character.initHealthIcon(true); - default: - this.characters.set(character.characterId, character); - } - - // Reset the character before adding it to the stage. - // This ensures positioning is based on the idle animation. - character.resetCharacter(true); - - if (charData != null) - { - character.zIndex = charData.zIndex; - - // Start with the per-stage character position. - // Subtracting the origin ensures characters are positioned relative to their feet. - // Subtracting the global offset allows positioning on a per-character basis. - character.x = charData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; - character.y = charData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; - - character.originalPosition.x = character.x; - character.originalPosition.y = character.y; - - character.cameraFocusPoint.x += charData.cameraOffsets[0]; - character.cameraFocusPoint.y += charData.cameraOffsets[1]; - - #if debug - // Draw the debug icon at the character's feet. - if (charType == BF || charType == DAD) - { - debugIcon.x = charData.position[0]; - debugIcon.y = charData.position[1]; - debugIcon2.x = character.x; - debugIcon2.y = character.y; - } - #end - } - - // Add the character to the scene. - this.add(character); - - #if debug - debugIconGroup.add(debugIcon); - debugIconGroup.add(debugIcon2); - #end - } - - public inline function getGirlfriendPosition():FlxPoint - { - return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]); - } - - public inline function getBoyfriendPosition():FlxPoint - { - return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]); - } - - public inline function getDadPosition():FlxPoint - { - return new FlxPoint(_data.characters.dad.position[0], _data.characters.dad.position[1]); - } - - /** - * Retrieves a given character from the stage. - */ - public function getCharacter(id:String):BaseCharacter - { - return this.characters.get(id); - } - - /** - * Retrieve the Boyfriend character. - * @param pop If true, the character will be removed from the stage as well. - */ - public function getBoyfriend(?pop:Bool = false):BaseCharacter - { - if (pop) - { - var boyfriend:BaseCharacter = getCharacter("bf"); - - // Remove the character from the stage. - this.remove(boyfriend); - this.characters.remove("bf"); - - return boyfriend; - } - else - { - return getCharacter('bf'); - } - } - - public function getGirlfriend():BaseCharacter - { - return getCharacter('gf'); - } - - public function getDad():BaseCharacter - { - return getCharacter('dad'); - } - - /** - * Retrieve a specific prop by the name assigned in the JSON file. - * @param name The name of the prop to retrieve. - * @return The corresponding FlxSprite. - */ - public function getNamedProp(name:String):FlxSprite - { - return this.namedProps.get(name); - } - - /** - * Retrieve a list of all the asset paths required to load the stage. - * Override this in a scripted class to ensure that all necessary assets are loaded! - * - * @return An array of file names. - */ - public function fetchAssetPaths():Array - { - var result:Array = []; - for (dataProp in _data.props) - { - result.push(Paths.image(dataProp.assetPath)); - } - return result; - } - - /** - * Dispatch an event to all the characters in the stage. - * @param event The script event to dispatch. - */ - public function dispatchToCharacters(event:ScriptEvent):Void - { - for (characterId in characters.keys()) - { - dispatchToCharacter(characterId, event); - } - } - - /** - * Dispatch an event to a specific character. - * @param characterId The ID of the character to dispatch to. - * @param event The script event to dispatch. - */ - public function dispatchToCharacter(characterId:String, event:ScriptEvent):Void - { - var character:BaseCharacter = getCharacter(characterId); - if (character != null) - { - ScriptEventDispatcher.callEvent(character, event); - } - } - - /** - * onDestroy gets called when the player is leaving the PlayState, - * and is used to clean up any objects that need to be destroyed. - */ - public function onDestroy(event:ScriptEvent):Void - { - // Make sure to call kill() when returning a stage to cache, - // and destroy() only when performing a hard cache refresh. - kill(); - - for (prop in this.namedProps) - { - if (prop != null) - { - remove(prop); - prop.kill(); - prop.destroy(); - } - } - namedProps.clear(); - - for (char in this.characters) - { - if (char != null) - { - remove(char); - char.kill(); - char.destroy(); - } - } - characters.clear(); - - for (bopper in boppers) - { - if (bopper != null) - { - remove(bopper); - bopper.kill(); - bopper.destroy(); - } - } - boppers = []; - - for (sprite in this.group) - { - if (sprite != null) - { - sprite.kill(); - sprite.destroy(); - remove(sprite); - } - } - group.clear(); - if (debugIconGroup != null && debugIconGroup.group != null) - { - debugIconGroup.kill(); - } - else - { - debugIconGroup = null; - } - } - - /** - * A function that gets called once per step in the song. - * @param curStep The current step number. - */ - public function onStepHit(event:SongTimeScriptEvent):Void - { - } - - /** - * A function that gets called once per beat in the song (once every four steps). - * @param curStep The current beat number. - */ - public function onBeatHit(event:SongTimeScriptEvent):Void - { - // Override me in your scripted stage to perform custom behavior! - // Make sure to call super.onBeatHit(curBeat) if you want to keep the boppers dancing. - - for (bopper in boppers) - { - ScriptEventDispatcher.callEvent(bopper, event); - } - } - - public function onUpdate(event:UpdateScriptEvent) - { - if (FlxG.keys.justPressed.F3) - { - debugIconGroup.visible = !debugIconGroup.visible; - } - } - - public function onScriptEvent(event:ScriptEvent) - { - } - - public function onPause(event:PauseScriptEvent) - { - } - - public function onResume(event:ScriptEvent) - { - } - - public function onSongStart(event:ScriptEvent) - { - } - - public function onSongEnd(event:ScriptEvent) - { - } - - public function onGameOver(event:ScriptEvent) - { - } - - public function onCountdownStart(event:CountdownScriptEvent) - { - } - - public function onCountdownStep(event:CountdownScriptEvent) - { - } - - public function onCountdownEnd(event:CountdownScriptEvent) - { - } - - public function onNoteHit(event:NoteScriptEvent) - { - } - - public function onNoteMiss(event:NoteScriptEvent) - { - } - - public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) - { - } - - public function onSongLoaded(event:SongLoadScriptEvent) - { - } - - public function onSongRetry(event:ScriptEvent) - { - } + public final stageId:String; + public final stageName:String; + + final _data:StageData; + + public var camZoom:Float = 1.0; + + var namedProps:Map = new Map(); + var characters:Map = new Map(); + var boppers:Array = new Array(); + + /** + * The Stage elements get initialized at the beginning of the game. + * They're used to cache the data needed to build the stage, + * then accessed and fleshed out when the stage needs to be built. + * + * @param stageId + */ + public function new(stageId:String) + { + super(); + + this.stageId = stageId; + _data = StageDataParser.parseStageData(this.stageId); + if (_data == null) + { + throw 'Could not find stage data for stageId: $stageId'; + } + else + { + this.stageName = _data.name; + } + } + + /** + * Called when the player is moving into the PlayState where the song will be played. + */ + public function onCreate(event:ScriptEvent):Void + { + buildStage(); + this.refresh(); + + debugIconGroup = new FlxSpriteGroup(); + debugIconGroup.visible = false; + debugIconGroup.zIndex = 1000000; + add(debugIconGroup); + } + + public function resetStage():Void + { + // Reset positions of characters. + if (getBoyfriend() != null) + { + getBoyfriend().resetCharacter(false); + } + else + { + trace('STAGE RESET: No boyfriend found.'); + } + if (getGirlfriend() != null) + { + getGirlfriend().resetCharacter(false); + } + if (getDad() != null) + { + getDad().resetCharacter(false); + } + + // Reset positions of named props. + for (dataProp in _data.props) + { + // Fetch the prop. + var prop:FlxSprite = getNamedProp(dataProp.name); + + if (prop != null) + { + // Reset the position. + prop.x = dataProp.position[0]; + prop.y = dataProp.position[1]; + prop.zIndex = dataProp.zIndex; + } + } + + // We can assume unnamed props are not moving. + } + + /** + * The default stage construction routine. Called when the stage is going to be played in. + * Instantiates each prop and adds it to the stage, while setting its parameters. + */ + function buildStage() + { + trace('Building stage for display: ${this.stageId}'); + + this.camZoom = _data.cameraZoom; + + this.debugIconGroup = new FlxSpriteGroup(); + + for (dataProp in _data.props) + { + trace(' Placing prop: ${dataProp.name} (${dataProp.assetPath})'); + + var isAnimated = dataProp.animations.length > 0; + + var propSprite:FlxSprite; + if (dataProp.danceEvery != 0) + { + propSprite = new Bopper(dataProp.danceEvery); + } + else + { + propSprite = new FlxSprite(); + } + + if (isAnimated) + { + // Initalize sprite frames. + switch (dataProp.animType) + { + case "packer": + propSprite.frames = Paths.getPackerAtlas(dataProp.assetPath); + default: // "sparrow" + propSprite.frames = Paths.getSparrowAtlas(dataProp.assetPath); + } + } + else + { + // Initalize static sprite. + propSprite.loadGraphic(Paths.image(dataProp.assetPath)); + + // Disables calls to update() for a performance boost. + propSprite.active = false; + } + + if (propSprite.frames == null || propSprite.frames.numFrames == 0) + { + trace(' ERROR: Could not build texture for prop.'); + continue; + } + + if (Std.isOfType(dataProp.scale, Array)) + { + propSprite.scale.set(dataProp.scale[0], dataProp.scale[1]); + } + else + { + propSprite.scale.set(dataProp.scale); + } + propSprite.updateHitbox(); + + propSprite.x = dataProp.position[0]; + propSprite.y = dataProp.position[1]; + + propSprite.alpha = dataProp.alpha; + + // If pixel, disable antialiasing. + propSprite.antialiasing = !dataProp.isPixel; + + propSprite.scrollFactor.x = dataProp.scroll[0]; + propSprite.scrollFactor.y = dataProp.scroll[1]; + + propSprite.zIndex = dataProp.zIndex; + + switch (dataProp.animType) + { + case "packer": + for (propAnim in dataProp.animations) + { + propSprite.animation.add(propAnim.name, propAnim.frameIndices); + + if (Std.isOfType(propSprite, Bopper)) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } + } + default: // "sparrow" + FlxAnimationUtil.addAtlasAnimations(propSprite, dataProp.animations); + if (Std.isOfType(propSprite, Bopper)) + { + for (propAnim in dataProp.animations) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } + } + } + + if (Std.isOfType(propSprite, Bopper)) + { + for (propAnim in dataProp.animations) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } + cast(propSprite, Bopper).originalPosition.x = dataProp.position[0]; + cast(propSprite, Bopper).originalPosition.y = dataProp.position[1]; + } + + if (dataProp.startingAnimation != null) + { + propSprite.animation.play(dataProp.startingAnimation); + } + + if (Std.isOfType(propSprite, Bopper)) + { + addBopper(cast propSprite, dataProp.name); + } + else + { + addProp(propSprite, dataProp.name); + } + } + } + + /** + * Add a sprite to the stage. + * @param prop The sprite to add. + * @param name (Optional) A unique name for the sprite. + * You can call `getNamedProp(name)` to retrieve it later. + */ + public function addProp(prop:FlxSprite, ?name:String = null) + { + if (name != null) + { + namedProps.set(name, prop); + } + this.add(prop); + } + + /** + * Add a sprite to the stage which animates to the beat of the song. + */ + public function addBopper(bopper:Bopper, ?name:String = null) + { + boppers.push(bopper); + this.addProp(bopper, name); + } + + /** + * Refreshes the stage, by redoing the render order of all props. + * It does this based on the `zIndex` of each prop. + */ + public function refresh() + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + + public function setShader(shader:FlxShader) + { + forEachAlive(function(prop:FlxSprite) + { + prop.shader = shader; + }); + } + + /** + * Adjusts the position and other properties of the soon-to-be child of this sprite group. + * Private helper to avoid duplicate code in `add()` and `insert()`. + * + * @param Sprite The sprite or sprite group that is about to be added or inserted into the group. + */ + override function preAdd(Sprite:FlxSprite):Void + { + var sprite:FlxSprite = cast Sprite; + sprite.x += x; + sprite.y += y; + sprite.alpha *= alpha; + // Don't override scroll factors. + // sprite.scrollFactor.copyFrom(scrollFactor); + sprite.cameras = _cameras; // _cameras instead of cameras because get_cameras() will not return null + + if (clipRect != null) + clipRectTransform(sprite, clipRect); + } + + var debugIconGroup:FlxSpriteGroup; + + /** + * Used by the PlayState to add a character to the stage. + */ + public function addCharacter(character:BaseCharacter, charType:CharacterType) + { + if (character == null) + return; + + #if debug + // Temporary marker that shows where the character's location is relative to. + // Should display at the stage position of the character (before any offsets). + // TODO: Make this a toggle? It's useful to turn on from time to time. + var debugIcon:FlxSprite = new FlxSprite(0, 0); + var debugIcon2:FlxSprite = new FlxSprite(0, 0); + debugIcon.makeGraphic(8, 8, 0xffff00ff); + debugIcon2.makeGraphic(8, 8, 0xff00ffff); + debugIcon.visible = true; + debugIcon2.visible = true; + debugIcon.zIndex = 1000000; + debugIcon2.zIndex = 1000000; + #end + + // Apply position and z-index. + var charData:StageDataCharacter = null; + switch (charType) + { + case BF: + this.characters.set("bf", character); + charData = _data.characters.bf; + character.flipX = !character.getDataFlipX(); + character.initHealthIcon(false); + case GF: + this.characters.set("gf", character); + charData = _data.characters.gf; + character.flipX = character.getDataFlipX(); + case DAD: + this.characters.set("dad", character); + charData = _data.characters.dad; + character.flipX = character.getDataFlipX(); + character.initHealthIcon(true); + default: + this.characters.set(character.characterId, character); + } + + // Reset the character before adding it to the stage. + // This ensures positioning is based on the idle animation. + character.resetCharacter(true); + + if (charData != null) + { + character.zIndex = charData.zIndex; + + // Start with the per-stage character position. + // Subtracting the origin ensures characters are positioned relative to their feet. + // Subtracting the global offset allows positioning on a per-character basis. + character.x = charData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; + character.y = charData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; + + character.originalPosition.x = character.x; + character.originalPosition.y = character.y; + + character.cameraFocusPoint.x += charData.cameraOffsets[0]; + character.cameraFocusPoint.y += charData.cameraOffsets[1]; + + #if debug + // Draw the debug icon at the character's feet. + if (charType == BF || charType == DAD) + { + debugIcon.x = charData.position[0]; + debugIcon.y = charData.position[1]; + debugIcon2.x = character.x; + debugIcon2.y = character.y; + } + #end + } + + // Add the character to the scene. + this.add(character); + + #if debug + debugIconGroup.add(debugIcon); + debugIconGroup.add(debugIcon2); + #end + } + + public inline function getGirlfriendPosition():FlxPoint + { + return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]); + } + + public inline function getBoyfriendPosition():FlxPoint + { + return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]); + } + + public inline function getDadPosition():FlxPoint + { + return new FlxPoint(_data.characters.dad.position[0], _data.characters.dad.position[1]); + } + + /** + * Retrieves a given character from the stage. + */ + public function getCharacter(id:String):BaseCharacter + { + return this.characters.get(id); + } + + /** + * Retrieve the Boyfriend character. + * @param pop If true, the character will be removed from the stage as well. + */ + public function getBoyfriend(?pop:Bool = false):BaseCharacter + { + if (pop) + { + var boyfriend:BaseCharacter = getCharacter("bf"); + + // Remove the character from the stage. + this.remove(boyfriend); + this.characters.remove("bf"); + + return boyfriend; + } + else + { + return getCharacter('bf'); + } + } + + public function getGirlfriend():BaseCharacter + { + return getCharacter('gf'); + } + + public function getDad():BaseCharacter + { + return getCharacter('dad'); + } + + /** + * Retrieve a specific prop by the name assigned in the JSON file. + * @param name The name of the prop to retrieve. + * @return The corresponding FlxSprite. + */ + public function getNamedProp(name:String):FlxSprite + { + return this.namedProps.get(name); + } + + /** + * Retrieve a list of all the asset paths required to load the stage. + * Override this in a scripted class to ensure that all necessary assets are loaded! + * + * @return An array of file names. + */ + public function fetchAssetPaths():Array + { + var result:Array = []; + for (dataProp in _data.props) + { + result.push(Paths.image(dataProp.assetPath)); + } + return result; + } + + /** + * Dispatch an event to all the characters in the stage. + * @param event The script event to dispatch. + */ + public function dispatchToCharacters(event:ScriptEvent):Void + { + for (characterId in characters.keys()) + { + dispatchToCharacter(characterId, event); + } + } + + /** + * Dispatch an event to a specific character. + * @param characterId The ID of the character to dispatch to. + * @param event The script event to dispatch. + */ + public function dispatchToCharacter(characterId:String, event:ScriptEvent):Void + { + var character:BaseCharacter = getCharacter(characterId); + if (character != null) + { + ScriptEventDispatcher.callEvent(character, event); + } + } + + /** + * onDestroy gets called when the player is leaving the PlayState, + * and is used to clean up any objects that need to be destroyed. + */ + public function onDestroy(event:ScriptEvent):Void + { + // Make sure to call kill() when returning a stage to cache, + // and destroy() only when performing a hard cache refresh. + kill(); + + for (prop in this.namedProps) + { + if (prop != null) + { + remove(prop); + prop.kill(); + prop.destroy(); + } + } + namedProps.clear(); + + for (char in this.characters) + { + if (char != null) + { + remove(char); + char.kill(); + char.destroy(); + } + } + characters.clear(); + + for (bopper in boppers) + { + if (bopper != null) + { + remove(bopper); + bopper.kill(); + bopper.destroy(); + } + } + boppers = []; + + for (sprite in this.group) + { + if (sprite != null) + { + sprite.kill(); + sprite.destroy(); + remove(sprite); + } + } + group.clear(); + if (debugIconGroup != null && debugIconGroup.group != null) + { + debugIconGroup.kill(); + } + else + { + debugIconGroup = null; + } + } + + /** + * A function that gets called once per step in the song. + * @param curStep The current step number. + */ + public function onStepHit(event:SongTimeScriptEvent):Void {} + + /** + * A function that gets called once per beat in the song (once every four steps). + * @param curStep The current beat number. + */ + public function onBeatHit(event:SongTimeScriptEvent):Void + { + // Override me in your scripted stage to perform custom behavior! + // Make sure to call super.onBeatHit(curBeat) if you want to keep the boppers dancing. + + for (bopper in boppers) + { + ScriptEventDispatcher.callEvent(bopper, event); + } + } + + public function onUpdate(event:UpdateScriptEvent) + { + if (FlxG.keys.justPressed.F3) + { + debugIconGroup.visible = !debugIconGroup.visible; + } + } + + public function onScriptEvent(event:ScriptEvent) {} + + public function onPause(event:PauseScriptEvent) {} + + public function onResume(event:ScriptEvent) {} + + public function onSongStart(event:ScriptEvent) {} + + public function onSongEnd(event:ScriptEvent) {} + + public function onGameOver(event:ScriptEvent) {} + + public function onCountdownStart(event:CountdownScriptEvent) {} + + public function onCountdownStep(event:CountdownScriptEvent) {} + + public function onCountdownEnd(event:CountdownScriptEvent) {} + + public function onNoteHit(event:NoteScriptEvent) {} + + public function onNoteMiss(event:NoteScriptEvent) {} + + public function onSongEvent(event:SongEventScriptEvent) {} + + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + + public function onSongLoaded(event:SongLoadScriptEvent) {} + + public function onSongRetry(event:ScriptEvent) {} } diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx index 380efc2fa..84f33fa80 100644 --- a/source/funkin/play/stage/StageData.hx +++ b/source/funkin/play/stage/StageData.hx @@ -13,507 +13,507 @@ import openfl.Assets; */ class StageDataParser { - /** - * The current version string for the stage data format. - * Handle breaking changes by incrementing this value - * and adding migration to the `migrateStageData()` function. - */ - public static final STAGE_DATA_VERSION:String = "1.0.0"; + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final STAGE_DATA_VERSION:String = "1.0.0"; - /** - * The current version rule check for the stage data format. - */ - public static final STAGE_DATA_VERSION_RULE:String = "1.0.x"; + /** + * The current version rule check for the stage data format. + */ + public static final STAGE_DATA_VERSION_RULE:String = "1.0.x"; - static final stageCache:Map = new Map(); + static final stageCache:Map = new Map(); - static final DEFAULT_STAGE_ID = 'UNKNOWN'; + static final DEFAULT_STAGE_ID = 'UNKNOWN'; - /** - * Parses and preloads the game's stage data and scripts when the game starts. - * - * If you want to force stages to be reloaded, you can just call this function again. - */ - public static function loadStageCache():Void - { - // Clear any stages that are cached if there were any. - clearStageCache(); - trace("[STAGEDATA] Loading stage cache..."); + /** + * Parses and preloads the game's stage data and scripts when the game starts. + * + * If you want to force stages to be reloaded, you can just call this function again. + */ + public static function loadStageCache():Void + { + // Clear any stages that are cached if there were any. + clearStageCache(); + trace("Loading stage cache..."); - // - // SCRIPTED STAGES - // - var scriptedStageClassNames:Array = ScriptedStage.listScriptClasses(); - trace(' Instantiating ${scriptedStageClassNames.length} scripted stages...'); - for (stageCls in scriptedStageClassNames) - { - var stage:Stage = ScriptedStage.init(stageCls, DEFAULT_STAGE_ID); - if (stage != null) - { - trace(' Loaded scripted stage: ${stage.stageName}'); - // Disable the rendering logic for stage until it's loaded. - // Note that kill() =/= destroy() - stage.kill(); + // + // SCRIPTED STAGES + // + var scriptedStageClassNames:Array = ScriptedStage.listScriptClasses(); + trace(' Instantiating ${scriptedStageClassNames.length} scripted stages...'); + for (stageCls in scriptedStageClassNames) + { + var stage:Stage = ScriptedStage.init(stageCls, DEFAULT_STAGE_ID); + if (stage != null) + { + trace(' Loaded scripted stage: ${stage.stageName}'); + // Disable the rendering logic for stage until it's loaded. + // Note that kill() =/= destroy() + stage.kill(); - // Then store it. - stageCache.set(stage.stageId, stage); - } - else - { - trace(' Failed to instantiate scripted stage class: ${stageCls}'); - } - } + // Then store it. + stageCache.set(stage.stageId, stage); + } + else + { + trace(' Failed to instantiate scripted stage class: ${stageCls}'); + } + } - // - // UNSCRIPTED STAGES - // - var stageIdList:Array = DataAssets.listDataFilesInPath('stages/'); - var unscriptedStageIds:Array = stageIdList.filter(function(stageId:String):Bool - { - return !stageCache.exists(stageId); - }); - trace(' Instantiating ${unscriptedStageIds.length} non-scripted stages...'); - for (stageId in unscriptedStageIds) - { - var stage:Stage; - try - { - stage = new Stage(stageId); - if (stage != null) - { - trace(' Loaded stage data: ${stage.stageName}'); - stageCache.set(stageId, stage); - } - } - catch (e) - { - trace(' An error occurred while loading stage data: ${stageId}'); - // Assume error was already logged. - continue; - } - } + // + // UNSCRIPTED STAGES + // + var stageIdList:Array = DataAssets.listDataFilesInPath('stages/'); + var unscriptedStageIds:Array = stageIdList.filter(function(stageId:String):Bool + { + return !stageCache.exists(stageId); + }); + trace(' Instantiating ${unscriptedStageIds.length} non-scripted stages...'); + for (stageId in unscriptedStageIds) + { + var stage:Stage; + try + { + stage = new Stage(stageId); + if (stage != null) + { + trace(' Loaded stage data: ${stage.stageName}'); + stageCache.set(stageId, stage); + } + } + catch (e) + { + trace(' An error occurred while loading stage data: ${stageId}'); + // Assume error was already logged. + continue; + } + } - trace(' Successfully loaded ${Lambda.count(stageCache)} stages.'); - } + trace(' Successfully loaded ${Lambda.count(stageCache)} stages.'); + } - public static function fetchStage(stageId:String):Null - { - if (stageCache.exists(stageId)) - { - trace('[STAGEDATA] Successfully fetch stage: ${stageId}'); - var stage:Stage = stageCache.get(stageId); - stage.revive(); - return stage; - } - else - { - trace('[STAGEDATA] Failed to fetch stage, not found in cache: ${stageId}'); - return null; - } - } + public static function fetchStage(stageId:String):Null + { + if (stageCache.exists(stageId)) + { + trace('Successfully fetch stage: ${stageId}'); + var stage:Stage = stageCache.get(stageId); + stage.revive(); + return stage; + } + else + { + trace('Failed to fetch stage, not found in cache: ${stageId}'); + return null; + } + } - static function clearStageCache():Void - { - if (stageCache != null) - { - for (stage in stageCache) - { - stage.destroy(); - } - stageCache.clear(); - } - } + static function clearStageCache():Void + { + if (stageCache != null) + { + for (stage in stageCache) + { + stage.destroy(); + } + stageCache.clear(); + } + } - /** - * Load a stage's JSON file, parse its data, and return it. - * - * @param stageId The stage to load. - * @return The stage data, or null if validation failed. - */ - public static function parseStageData(stageId:String):Null - { - var rawJson:String = loadStageFile(stageId); + /** + * Load a stage's JSON file, parse its data, and return it. + * + * @param stageId The stage to load. + * @return The stage data, or null if validation failed. + */ + public static function parseStageData(stageId:String):Null + { + var rawJson:String = loadStageFile(stageId); - var stageData:StageData = migrateStageData(rawJson, stageId); + var stageData:StageData = migrateStageData(rawJson, stageId); - return validateStageData(stageId, stageData); - } + return validateStageData(stageId, stageData); + } - public static function listStageIds():Array - { - return stageCache.keys().array(); - } + public static function listStageIds():Array + { + return stageCache.keys().array(); + } - static function loadStageFile(stagePath:String):String - { - var stageFilePath:String = Paths.json('stages/${stagePath}'); - var rawJson = Assets.getText(stageFilePath).trim(); + static function loadStageFile(stagePath:String):String + { + var stageFilePath:String = Paths.json('stages/${stagePath}'); + var rawJson = Assets.getText(stageFilePath).trim(); - while (!rawJson.endsWith("}")) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } + while (!rawJson.endsWith("}")) + { + rawJson = rawJson.substr(0, rawJson.length - 1); + } - return rawJson; - } + return rawJson; + } - static function migrateStageData(rawJson:String, stageId:String) - { - // If you update the stage data format in a breaking way, - // handle migration here by checking the `version` value. + static function migrateStageData(rawJson:String, stageId:String) + { + // If you update the stage data format in a breaking way, + // handle migration here by checking the `version` value. - try - { - var stageData:StageData = cast Json.parse(rawJson); - return stageData; - } - catch (e) - { - trace(' Error parsing data for stage: ${stageId}'); - trace(' ${e}'); - return null; - } - } + try + { + var stageData:StageData = cast Json.parse(rawJson); + return stageData; + } + catch (e) + { + trace(' Error parsing data for stage: ${stageId}'); + trace(' ${e}'); + return null; + } + } - static final DEFAULT_ANIMTYPE:String = "sparrow"; - static final DEFAULT_CAMERAZOOM:Float = 1.0; - static final DEFAULT_DANCEEVERY:Int = 0; - static final DEFAULT_ISPIXEL:Bool = false; - static final DEFAULT_NAME:String = "Untitled Stage"; - static final DEFAULT_OFFSETS:Array = [0, 0]; - static final DEFAULT_CAMERA_OFFSETS_BF:Array = [-100, -100]; - static final DEFAULT_CAMERA_OFFSETS_DAD:Array = [150, -100]; - static final DEFAULT_POSITION:Array = [0, 0]; - static final DEFAULT_SCALE:Float = 1.0; - static final DEFAULT_ALPHA:Float = 1.0; - static final DEFAULT_SCROLL:Array = [0, 0]; - static final DEFAULT_ZINDEX:Int = 0; + static final DEFAULT_ANIMTYPE:String = "sparrow"; + static final DEFAULT_CAMERAZOOM:Float = 1.0; + static final DEFAULT_DANCEEVERY:Int = 0; + static final DEFAULT_ISPIXEL:Bool = false; + static final DEFAULT_NAME:String = "Untitled Stage"; + static final DEFAULT_OFFSETS:Array = [0, 0]; + static final DEFAULT_CAMERA_OFFSETS_BF:Array = [-100, -100]; + static final DEFAULT_CAMERA_OFFSETS_DAD:Array = [150, -100]; + static final DEFAULT_POSITION:Array = [0, 0]; + static final DEFAULT_SCALE:Float = 1.0; + static final DEFAULT_ALPHA:Float = 1.0; + static final DEFAULT_SCROLL:Array = [0, 0]; + static final DEFAULT_ZINDEX:Int = 0; - static final DEFAULT_CHARACTER_DATA:StageDataCharacter = { - zIndex: DEFAULT_ZINDEX, - position: DEFAULT_POSITION, - cameraOffsets: DEFAULT_OFFSETS, - } + static final DEFAULT_CHARACTER_DATA:StageDataCharacter = { + zIndex: DEFAULT_ZINDEX, + position: DEFAULT_POSITION, + cameraOffsets: DEFAULT_OFFSETS, + } - /** - * Set unspecified parameters to their defaults. - * If the parameter is mandatory, print an error message. - * @param id - * @param input - * @return The validated stage data - */ - static function validateStageData(id:String, input:StageData):Null - { - if (input == null) - { - trace('[STAGEDATA] ERROR: Could not parse stage data for "${id}".'); - return null; - } + /** + * Set unspecified parameters to their defaults. + * If the parameter is mandatory, print an error message. + * @param id + * @param input + * @return The validated stage data + */ + static function validateStageData(id:String, input:StageData):Null + { + if (input == null) + { + trace('ERROR: Could not parse stage data for "${id}".'); + return null; + } - if (input.version == null) - { - trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing version'); - return null; - } + if (input.version == null) + { + trace('ERROR: Could not load stage data for "$id": missing version'); + return null; + } - if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE)) - { - trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})'); - return null; - } + if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE)) + { + trace('ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})'); + return null; + } - if (input.name == null) - { - trace('[STAGEDATA] WARN: Stage data for "$id" missing name'); - input.name = DEFAULT_NAME; - } + if (input.name == null) + { + trace('WARN: Stage data for "$id" missing name'); + input.name = DEFAULT_NAME; + } - if (input.cameraZoom == null) - { - input.cameraZoom = DEFAULT_CAMERAZOOM; - } + if (input.cameraZoom == null) + { + input.cameraZoom = DEFAULT_CAMERAZOOM; + } - if (input.props == null) - { - input.props = []; - } + if (input.props == null) + { + input.props = []; + } - for (inputProp in input.props) - { - // It's fine for inputProp.name to be null + for (inputProp in input.props) + { + // It's fine for inputProp.name to be null - if (inputProp.assetPath == null) - { - trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing assetPath for prop "${inputProp.name}"'); - return null; - } + if (inputProp.assetPath == null) + { + trace('ERROR: Could not load stage data for "$id": missing assetPath for prop "${inputProp.name}"'); + return null; + } - if (inputProp.position == null) - { - inputProp.position = DEFAULT_POSITION; - } + if (inputProp.position == null) + { + inputProp.position = DEFAULT_POSITION; + } - if (inputProp.zIndex == null) - { - inputProp.zIndex = DEFAULT_ZINDEX; - } + if (inputProp.zIndex == null) + { + inputProp.zIndex = DEFAULT_ZINDEX; + } - if (inputProp.isPixel == null) - { - inputProp.isPixel = DEFAULT_ISPIXEL; - } + if (inputProp.isPixel == null) + { + inputProp.isPixel = DEFAULT_ISPIXEL; + } - if (inputProp.danceEvery == null) - { - inputProp.danceEvery = DEFAULT_DANCEEVERY; - } + if (inputProp.danceEvery == null) + { + inputProp.danceEvery = DEFAULT_DANCEEVERY; + } - if (inputProp.scale == null) - { - inputProp.scale = DEFAULT_SCALE; - } + if (inputProp.scale == null) + { + inputProp.scale = DEFAULT_SCALE; + } - if (inputProp.animType == null) - { - inputProp.animType = DEFAULT_ANIMTYPE; - } + if (inputProp.animType == null) + { + inputProp.animType = DEFAULT_ANIMTYPE; + } - if (Std.isOfType(inputProp.scale, Float)) - { - inputProp.scale = [inputProp.scale, inputProp.scale]; - } + if (Std.isOfType(inputProp.scale, Float)) + { + inputProp.scale = [inputProp.scale, inputProp.scale]; + } - if (inputProp.scroll == null) - { - inputProp.scroll = DEFAULT_SCROLL; - } + if (inputProp.scroll == null) + { + inputProp.scroll = DEFAULT_SCROLL; + } - if (inputProp.alpha == null) - { - inputProp.alpha = DEFAULT_ALPHA; - } + if (inputProp.alpha == null) + { + inputProp.alpha = DEFAULT_ALPHA; + } - if (Std.isOfType(inputProp.scroll, Float)) - { - inputProp.scroll = [inputProp.scroll, inputProp.scroll]; - } + if (Std.isOfType(inputProp.scroll, Float)) + { + inputProp.scroll = [inputProp.scroll, inputProp.scroll]; + } - if (inputProp.animations == null) - { - inputProp.animations = []; - } + if (inputProp.animations == null) + { + inputProp.animations = []; + } - if (inputProp.animations.length == 0 && inputProp.startingAnimation != null) - { - trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing animations for prop "${inputProp.name}"'); - return null; - } + if (inputProp.animations.length == 0 && inputProp.startingAnimation != null) + { + trace('ERROR: Could not load stage data for "$id": missing animations for prop "${inputProp.name}"'); + return null; + } - for (inputAnimation in inputProp.animations) - { - if (inputAnimation.name == null) - { - trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing animation name for prop "${inputProp.name}"'); - return null; - } + for (inputAnimation in inputProp.animations) + { + if (inputAnimation.name == null) + { + trace('ERROR: Could not load stage data for "$id": missing animation name for prop "${inputProp.name}"'); + return null; + } - if (inputAnimation.frameRate == null) - { - inputAnimation.frameRate = 24; - } + if (inputAnimation.frameRate == null) + { + inputAnimation.frameRate = 24; + } - if (inputAnimation.offsets == null) - { - inputAnimation.offsets = DEFAULT_OFFSETS; - } + if (inputAnimation.offsets == null) + { + inputAnimation.offsets = DEFAULT_OFFSETS; + } - if (inputAnimation.looped == null) - { - inputAnimation.looped = true; - } + if (inputAnimation.looped == null) + { + inputAnimation.looped = true; + } - if (inputAnimation.flipX == null) - { - inputAnimation.flipX = false; - } + if (inputAnimation.flipX == null) + { + inputAnimation.flipX = false; + } - if (inputAnimation.flipY == null) - { - inputAnimation.flipY = false; - } - } - } + if (inputAnimation.flipY == null) + { + inputAnimation.flipY = false; + } + } + } - if (input.characters == null) - { - trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing characters'); - return null; - } + if (input.characters == null) + { + trace('ERROR: Could not load stage data for "$id": missing characters'); + return null; + } - if (input.characters.bf == null) - { - input.characters.bf = DEFAULT_CHARACTER_DATA; - input.characters.bf.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF; - } - if (input.characters.dad == null) - { - input.characters.dad = DEFAULT_CHARACTER_DATA; - input.characters.dad.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD; - } - if (input.characters.gf == null) - { - input.characters.gf = DEFAULT_CHARACTER_DATA; - } + if (input.characters.bf == null) + { + input.characters.bf = DEFAULT_CHARACTER_DATA; + input.characters.bf.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF; + } + if (input.characters.dad == null) + { + input.characters.dad = DEFAULT_CHARACTER_DATA; + input.characters.dad.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD; + } + if (input.characters.gf == null) + { + input.characters.gf = DEFAULT_CHARACTER_DATA; + } - for (inputCharacter in [input.characters.bf, input.characters.dad, input.characters.gf]) - { - if (inputCharacter.zIndex == null) - { - inputCharacter.zIndex = 0; - } - if (inputCharacter.position == null || inputCharacter.position.length != 2) - { - inputCharacter.position = [0, 0]; - } - if (inputCharacter.cameraOffsets == null || inputCharacter.cameraOffsets.length != 2) - { - if (inputCharacter == input.characters.bf) - inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF; - else if (inputCharacter == input.characters.dad) - inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD; - else - { - inputCharacter.cameraOffsets = [0, 0]; - } - } - } + for (inputCharacter in [input.characters.bf, input.characters.dad, input.characters.gf]) + { + if (inputCharacter.zIndex == null) + { + inputCharacter.zIndex = 0; + } + if (inputCharacter.position == null || inputCharacter.position.length != 2) + { + inputCharacter.position = [0, 0]; + } + if (inputCharacter.cameraOffsets == null || inputCharacter.cameraOffsets.length != 2) + { + if (inputCharacter == input.characters.bf) + inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF; + else if (inputCharacter == input.characters.dad) + inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD; + else + { + inputCharacter.cameraOffsets = [0, 0]; + } + } + } - // All good! - return input; - } + // All good! + return input; + } } typedef StageData = { - /** - * The sematic version number of the stage data JSON format. - * Supports fancy comparisons like NPM does it's neat. - */ - var version:String; + /** + * The sematic version number of the stage data JSON format. + * Supports fancy comparisons like NPM does it's neat. + */ + var version:String; - var name:String; - var cameraZoom:Null; - var props:Array; - var characters: - { - bf:StageDataCharacter, - dad:StageDataCharacter, - gf:StageDataCharacter, - }; + var name:String; + var cameraZoom:Null; + var props:Array; + var characters: + { + bf:StageDataCharacter, + dad:StageDataCharacter, + gf:StageDataCharacter, + }; }; typedef StageDataProp = { - /** - * The name of the prop for later lookup by scripts. - * Optional; if unspecified, the prop can't be referenced by scripts. - */ - var name:String; + /** + * The name of the prop for later lookup by scripts. + * Optional; if unspecified, the prop can't be referenced by scripts. + */ + var name:String; - /** - * The asset used to display the prop. - */ - var assetPath:String; + /** + * The asset used to display the prop. + */ + var assetPath:String; - /** - * The position of the prop as an [x, y] array of two floats. - */ - var position:Array; + /** + * The position of the prop as an [x, y] array of two floats. + */ + var position:Array; - /** - * A number determining the stack order of the prop, relative to other props and the characters in the stage. - * Props with lower numbers render below those with higher numbers. - * This is just like CSS, it isn't hard. - * @default 0 - */ - var zIndex:Null; + /** + * A number determining the stack order of the prop, relative to other props and the characters in the stage. + * Props with lower numbers render below those with higher numbers. + * This is just like CSS, it isn't hard. + * @default 0 + */ + var zIndex:Null; - /** - * If set to true, anti-aliasing will be forcibly disabled on the sprite. - * This prevents blurry images on pixel-art levels. - * @default false - */ - var isPixel:Null; + /** + * If set to true, anti-aliasing will be forcibly disabled on the sprite. + * This prevents blurry images on pixel-art levels. + * @default false + */ + var isPixel:Null; - /** - * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats. - * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory. - * @default 1 - */ - var scale:OneOfTwo>; + /** + * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats. + * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory. + * @default 1 + */ + var scale:OneOfTwo>; - /** - * The alpha of the prop, as a float. - * @default 1.0 - */ - var alpha:Null; + /** + * The alpha of the prop, as a float. + * @default 1.0 + */ + var alpha:Null; - /** - * If not zero, this prop will play an animation every X beats of the song. - * This requires animations to be defined. If `danceLeft` and `danceRight` are defined, - * they will alternated between, otherwise the `idle` animation will be used. - * - * @default 0 - */ - var danceEvery:Null; + /** + * If not zero, this prop will play an animation every X beats of the song. + * This requires animations to be defined. If `danceLeft` and `danceRight` are defined, + * they will alternated between, otherwise the `idle` animation will be used. + * + * @default 0 + */ + var danceEvery:Null; - /** - * How much the prop scrolls relative to the camera. Used to create a parallax effect. - * Represented as a float or as an [x, y] array of two floats. - * [1, 1] means the prop moves 1:1 with the camera. - * [0.5, 0.5] means the prop half as much as the camera. - * [0, 0] means the prop is not moved. - * @default [0, 0] - */ - var scroll:OneOfTwo>; + /** + * How much the prop scrolls relative to the camera. Used to create a parallax effect. + * Represented as a float or as an [x, y] array of two floats. + * [1, 1] means the prop moves 1:1 with the camera. + * [0.5, 0.5] means the prop half as much as the camera. + * [0, 0] means the prop is not moved. + * @default [0, 0] + */ + var scroll:OneOfTwo>; - /** - * An optional array of animations which the prop can play. - * @default Prop has no animations. - */ - var animations:Array; + /** + * An optional array of animations which the prop can play. + * @default Prop has no animations. + */ + var animations:Array; - /** - * If animations are used, this is the name of the animation to play first. - * @default Don't play an animation. - */ - var startingAnimation:String; + /** + * If animations are used, this is the name of the animation to play first. + * @default Don't play an animation. + */ + var startingAnimation:String; - /** - * The animation type to use. - * Options: "sparrow", "packer" - * @default "sparrow" - */ - var animType:String; + /** + * The animation type to use. + * Options: "sparrow", "packer" + * @default "sparrow" + */ + var animType:String; }; typedef StageDataCharacter = { - /** - * A number determining the stack order of the character, relative to props and other characters in the stage. - * Again, just like CSS. - * @default 0 - */ - zIndex:Null, + /** + * A number determining the stack order of the character, relative to props and other characters in the stage. + * Again, just like CSS. + * @default 0 + */ + zIndex:Null, - /** - * The position to render the character at. - */ - position:Array, + /** + * The position to render the character at. + */ + position:Array, - /** - * The camera offsets to apply when focusing on the character on this stage. - * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF - */ - cameraOffsets:Array, + /** + * The camera offsets to apply when focusing on the character on this stage. + * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF + */ + cameraOffsets:Array, }; diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx index 64fab3866..3e3f3774f 100644 --- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx +++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx @@ -55,11 +55,12 @@ class AddNotesCommand implements ChartEditorCommand if (appendToSelection) { - state.currentSelection = state.currentSelection.concat(notes); + state.currentNoteSelection = state.currentNoteSelection.concat(notes); } else { - state.currentSelection = notes; + state.currentNoteSelection = notes; + state.currentEventSelection = []; } state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); @@ -74,7 +75,8 @@ class AddNotesCommand implements ChartEditorCommand public function undo(state:ChartEditorState):Void { state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); - state.currentSelection = []; + state.currentNoteSelection = []; + state.currentEventSelection = []; state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.saveDataDirty = true; @@ -108,7 +110,8 @@ class RemoveNotesCommand implements ChartEditorCommand public function execute(state:ChartEditorState):Void { state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); - state.currentSelection = []; + state.currentNoteSelection = []; + state.currentEventSelection = []; state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.saveDataDirty = true; @@ -124,7 +127,8 @@ class RemoveNotesCommand implements ChartEditorCommand { state.currentSongChartNoteData.push(note); } - state.currentSelection = notes; + state.currentNoteSelection = notes; + state.currentEventSelection = []; state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.saveDataDirty = true; @@ -146,6 +150,241 @@ class RemoveNotesCommand implements ChartEditorCommand } } +/** + * Appends one or more items to the selection. + */ +class SelectItemsCommand implements ChartEditorCommand +{ + private var notes:Array; + private var events:Array; + + public function new(notes:Array, events:Array) + { + this.notes = notes; + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + for (note in this.notes) + { + state.currentNoteSelection.push(note); + } + + for (event in this.events) + { + state.currentEventSelection.push(event); + } + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes); + state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events); + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function toString():String + { + var len:Int = notes.length + events.length; + + if (notes.length == 0) + { + if (events.length == 1) + { + return 'Select Event'; + } + else + { + return 'Select ${events.length} Events'; + } + } + else if (events.length == 0) + { + if (notes.length == 1) + { + return 'Select Note'; + } + else + { + return 'Select ${notes.length} Notes'; + } + } + + return 'Select ${len} Items'; + } +} + +class AddEventsCommand implements ChartEditorCommand +{ + private var events:Array; + private var appendToSelection:Bool; + + public function new(events:Array, ?appendToSelection:Bool = false) + { + this.events = events; + this.appendToSelection = appendToSelection; + } + + public function execute(state:ChartEditorState):Void + { + for (event in events) + { + state.currentSongChartEventData.push(event); + } + + if (appendToSelection) + { + state.currentEventSelection = state.currentEventSelection.concat(events); + } + else + { + state.currentNoteSelection = []; + state.currentEventSelection = events; + } + + state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + + state.currentNoteSelection = []; + state.currentEventSelection = []; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function toString():String + { + var len:Int = events.length; + return 'Add $len Events'; + } +} + +class RemoveEventsCommand implements ChartEditorCommand +{ + private var events:Array; + + public function new(events:Array) + { + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + state.currentEventSelection = []; + state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + for (event in events) + { + state.currentSongChartEventData.push(event); + } + state.currentEventSelection = events; + state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function toString():String + { + if (events.length == 1 && events[0] != null) + { + return 'Remove Event'; + } + + return 'Remove ${events.length} Events'; + } +} + +class RemoveItemsCommand implements ChartEditorCommand +{ + private var notes:Array; + private var events:Array; + + public function new(notes:Array, events:Array) + { + this.notes = notes; + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + + state.currentNoteSelection = []; + state.currentEventSelection = []; + + state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + for (note in notes) + { + state.currentSongChartNoteData.push(note); + } + + for (event in events) + { + state.currentSongChartEventData.push(event); + } + + state.currentNoteSelection = notes; + state.currentEventSelection = events; + + state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function toString():String + { + return 'Remove ${notes.length + events.length} Items'; + } +} + class SwitchDifficultyCommand implements ChartEditorCommand { private var prevDifficulty:String; @@ -185,61 +424,21 @@ class SwitchDifficultyCommand implements ChartEditorCommand } } -/** - * Adds one or more notes to the selection. - */ -class SelectNotesCommand implements ChartEditorCommand +class DeselectItemsCommand implements ChartEditorCommand { private var notes:Array; + private var events:Array; - public function new(notes:Array) + public function new(notes:Array, events:Array) { this.notes = notes; + this.events = events; } public function execute(state:ChartEditorState):Void { - for (note in this.notes) - { - state.currentSelection.push(note); - } - - state.noteDisplayDirty = true; - state.notePreviewDirty = true; - } - - public function undo(state:ChartEditorState):Void - { - state.currentSelection = SongDataUtils.subtractNotes(state.currentSelection, this.notes); - - state.noteDisplayDirty = true; - state.notePreviewDirty = true; - } - - public function toString():String - { - if (notes.length == 1) - { - var dir:String = notes[0].getDirectionName(); - return 'Select $dir Note'; - } - - return 'Select ${notes.length} Notes'; - } -} - -class DeselectNotesCommand implements ChartEditorCommand -{ - private var notes:Array; - - public function new(notes:Array) - { - this.notes = notes; - } - - public function execute(state:ChartEditorState):Void - { - state.currentSelection = SongDataUtils.subtractNotes(state.currentSelection, this.notes); + state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes); + state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events); state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -249,7 +448,12 @@ class DeselectNotesCommand implements ChartEditorCommand { for (note in this.notes) { - state.currentSelection.push(note); + state.currentNoteSelection.push(note); + } + + for (event in this.events) + { + state.currentEventSelection.push(event); } state.noteDisplayDirty = true; @@ -258,13 +462,15 @@ class DeselectNotesCommand implements ChartEditorCommand public function toString():String { - if (notes.length == 1) + var noteCount = notes.length + events.length; + + if (noteCount == 1) { var dir:String = notes[0].getDirectionName(); - return 'Deselect $dir Note'; + return 'Deselect $dir Items'; } - return 'Deselect ${notes.length} Notes'; + return 'Deselect ${noteCount} Items'; } } @@ -272,20 +478,26 @@ class DeselectNotesCommand implements ChartEditorCommand * Sets the selection rather than appends it. * Deselects any notes that are not in the new selection. */ -class SetNoteSelectionCommand implements ChartEditorCommand +class SetItemSelectionCommand implements ChartEditorCommand { private var notes:Array; - private var previousSelection:Array; + private var events:Array; + private var previousNoteSelection:Array; + private var previousEventSelection:Array; - public function new(notes:Array, ?previousSelection:Array) + public function new(notes:Array, events:Array, previousNoteSelection:Array, + previousEventSelection:Array) { this.notes = notes; - this.previousSelection = previousSelection == null ? [] : previousSelection; + this.events = events; + this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection; + this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection; } public function execute(state:ChartEditorState):Void { - state.currentSelection = notes; + state.currentNoteSelection = notes; + state.currentEventSelection = events; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -293,7 +505,8 @@ class SetNoteSelectionCommand implements ChartEditorCommand public function undo(state:ChartEditorState):Void { - state.currentSelection = previousSelection; + state.currentNoteSelection = previousNoteSelection; + state.currentEventSelection = previousEventSelection; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -301,29 +514,34 @@ class SetNoteSelectionCommand implements ChartEditorCommand public function toString():String { - return 'Select ${notes.length} Notes'; + return 'Select ${notes.length} Items'; } } -class SelectAllNotesCommand implements ChartEditorCommand +class SelectAllItemsCommand implements ChartEditorCommand { - private var previousSelection:Array; + private var previousNoteSelection:Array; + private var previousEventSelection:Array; - public function new(?previousSelection:Array) + public function new(?previousNoteSelection:Array, ?previousEventSelection:Array) { - this.previousSelection = previousSelection == null ? [] : previousSelection; + this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection; + this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection; } public function execute(state:ChartEditorState):Void { - state.currentSelection = state.currentSongChartNoteData; + state.currentNoteSelection = state.currentSongChartNoteData; + state.currentEventSelection = state.currentSongChartEventData; + state.noteDisplayDirty = true; state.notePreviewDirty = true; } public function undo(state:ChartEditorState):Void { - state.currentSelection = previousSelection; + state.currentNoteSelection = previousNoteSelection; + state.currentEventSelection = previousEventSelection; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -331,29 +549,33 @@ class SelectAllNotesCommand implements ChartEditorCommand public function toString():String { - return 'Select All Notes'; + return 'Select All Items'; } } -class InvertSelectedNotesCommand implements ChartEditorCommand +class InvertSelectedItemsCommand implements ChartEditorCommand { - private var previousSelection:Array; + private var previousNoteSelection:Array; + private var previousEventSelection:Array; - public function new(?previousSelection:Array) + public function new(?previousNoteSelection:Array, ?previousEventSelection:Array) { - this.previousSelection = previousSelection == null ? [] : previousSelection; + this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection; + this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection; } public function execute(state:ChartEditorState):Void { - state.currentSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousSelection); + state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousNoteSelection); + state.currentEventSelection = SongDataUtils.subtractEvents(state.currentSongChartEventData, previousEventSelection); state.noteDisplayDirty = true; state.notePreviewDirty = true; } public function undo(state:ChartEditorState):Void { - state.currentSelection = previousSelection; + state.currentNoteSelection = previousNoteSelection; + state.currentEventSelection = previousEventSelection; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -361,22 +583,25 @@ class InvertSelectedNotesCommand implements ChartEditorCommand public function toString():String { - return 'Invert Selected Notes'; + return 'Invert Selected Items'; } } -class DeselectAllNotesCommand implements ChartEditorCommand +class DeselectAllItemsCommand implements ChartEditorCommand { - private var previousSelection:Array; + private var previousNoteSelection:Array; + private var previousEventSelection:Array; - public function new(?previousSelection:Array) + public function new(?previousNoteSelection:Array, ?previousEventSelection:Array) { - this.previousSelection = previousSelection == null ? [] : previousSelection; + this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection; + this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection; } public function execute(state:ChartEditorState):Void { - state.currentSelection = []; + state.currentNoteSelection = []; + state.currentEventSelection = []; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -384,7 +609,8 @@ class DeselectAllNotesCommand implements ChartEditorCommand public function undo(state:ChartEditorState):Void { - state.currentSelection = previousSelection; + state.currentNoteSelection = previousNoteSelection; + state.currentEventSelection = previousEventSelection; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -392,27 +618,35 @@ class DeselectAllNotesCommand implements ChartEditorCommand public function toString():String { - return 'Deselect All Notes'; + return 'Deselect All Items'; } } -class CutNotesCommand implements ChartEditorCommand +class CutItemsCommand implements ChartEditorCommand { private var notes:Array; + private var events:Array; - public function new(notes:Array) + public function new(notes:Array, events:Array) { this.notes = notes; + this.events = events; } public function execute(state:ChartEditorState):Void { // Copy the notes. - SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(notes)); + SongDataUtils.writeItemsToClipboard({ + notes: SongDataUtils.buildNoteClipboard(notes), + events: SongDataUtils.buildEventClipboard(events) + }); // Delete the notes. state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); - state.currentSelection = []; + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + state.currentNoteSelection = []; + state.currentEventSelection = []; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -422,19 +656,27 @@ class CutNotesCommand implements ChartEditorCommand public function undo(state:ChartEditorState):Void { state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); - state.currentSelection = notes; + state.currentSongChartEventData = state.currentSongChartEventData.concat(events); + + state.currentNoteSelection = notes; + state.currentEventSelection = events; state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; - state.sortChartData(); } public function toString():String { - var len:Int = notes.length; - return 'Cut $len Notes to Clipboard'; + var len:Int = notes.length + events.length; + + if (notes.length == 0) + return 'Cut $len Events to Clipboard'; + else if (events.length == 0) + return 'Cut $len Notes to Clipboard'; + else + return 'Cut $len Items to Clipboard'; } } @@ -457,7 +699,8 @@ class FlipNotesCommand implements ChartEditorCommand flippedNotes = SongDataUtils.flipNotes(notes); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(flippedNotes); - state.currentSelection = flippedNotes; + state.currentNoteSelection = flippedNotes; + state.currentEventSelection = []; state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -470,7 +713,8 @@ class FlipNotesCommand implements ChartEditorCommand state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, flippedNotes); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); - state.currentSelection = notes; + state.currentNoteSelection = notes; + state.currentEventSelection = []; state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -486,11 +730,12 @@ class FlipNotesCommand implements ChartEditorCommand } } -class PasteNotesCommand implements ChartEditorCommand +class PasteItemsCommand implements ChartEditorCommand { private var targetTimestamp:Float; // Notes we added with this command, for undo. private var addedNotes:Array; + private var addedEvents:Array; public function new(targetTimestamp:Float) { @@ -499,12 +744,15 @@ class PasteNotesCommand implements ChartEditorCommand public function execute(state:ChartEditorState):Void { - var currentClipboard:Array = SongDataUtils.readNotesFromClipboard(); + var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard(); - addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard, Std.int(targetTimestamp)); + addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp)); + addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp)); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes); - state.currentSelection = addedNotes.copy(); + state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents); + state.currentNoteSelection = addedNotes.copy(); + state.currentEventSelection = addedEvents.copy(); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -516,7 +764,9 @@ class PasteNotesCommand implements ChartEditorCommand public function undo(state:ChartEditorState):Void { state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes); - state.currentSelection = []; + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, addedEvents); + state.currentNoteSelection = []; + state.currentEventSelection = []; state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -527,52 +777,16 @@ class PasteNotesCommand implements ChartEditorCommand public function toString():String { - var currentClipboard:Array = SongDataUtils.readNotesFromClipboard(); - return 'Paste ${currentClipboard.length} Notes from Clipboard'; - } -} + var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard(); -class AddEventsCommand implements ChartEditorCommand -{ - private var events:Array; - private var appendToSelection:Bool; + var len:Int = currentClipboard.notes.length + currentClipboard.events.length; - public function new(events:Array, ?appendToSelection:Bool = false) - { - this.events = events; - this.appendToSelection = appendToSelection; - } - - public function execute(state:ChartEditorState):Void - { - state.currentSongChartEventData = state.currentSongChartEventData.concat(events); - // TODO: Allow selecting events. - // state.currentSelection = events; - - state.saveDataDirty = true; - state.noteDisplayDirty = true; - state.notePreviewDirty = true; - - state.sortChartData(); - } - - public function undo(state:ChartEditorState):Void - { - state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); - - state.currentSelection = []; - - state.saveDataDirty = true; - state.noteDisplayDirty = true; - state.notePreviewDirty = true; - - state.sortChartData(); - } - - public function toString():String - { - var len:Int = events.length; - return 'Add $len Events'; + if (currentClipboard.notes.length == 0) + return 'Paste $len Events'; + else if (currentClipboard.events.length == 0) + return 'Paste $len Notes'; + else + return 'Paste $len Items'; } } diff --git a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx new file mode 100644 index 000000000..1c1580f7a --- /dev/null +++ b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx @@ -0,0 +1,101 @@ +package funkin.ui.debug.charting; + +import openfl.display.BitmapData; +import openfl.utils.Assets; +import flixel.FlxObject; +import flixel.FlxBasic; +import flixel.FlxSprite; +import flixel.graphics.frames.FlxFramesCollection; +import flixel.graphics.frames.FlxTileFrames; +import flixel.math.FlxPoint; +import funkin.play.song.SongData.SongEventData; + +/** + * A event sprite that can be used to display a song event in a chart. + * Designed to be used and reused efficiently. Has no gameplay functionality. + */ +class ChartEditorEventSprite extends FlxSprite +{ + public var parentState:ChartEditorState; + + /** + * The note data that this sprite represents. + * You can set this to null to kill the sprite and flag it for recycling. + */ + public var eventData(default, set):SongEventData; + + /** + * The image used for all song events. Cached for performance. + */ + var eventGraphic:BitmapData; + + public function new(parent:ChartEditorState) + { + super(); + + this.parentState = parent; + + buildGraphic(); + } + + function buildGraphic():Void + { + if (eventGraphic == null) + { + eventGraphic = Assets.getBitmapData(Paths.image('ui/chart-editor/event')); + } + + loadGraphic(eventGraphic); + setGraphicSize(ChartEditorState.GRID_SIZE); + this.updateHitbox(); + } + + function set_eventData(value:SongEventData):SongEventData + { + this.eventData = value; + + if (this.eventData == null) + { + // Disown parent. + this.kill(); + return this.eventData; + } + + this.visible = true; + + // Update the position to match the note data. + updateEventPosition(); + + return this.eventData; + } + + public function updateEventPosition(?origin:FlxObject) + { + this.x = (ChartEditorState.STRUMLINE_SIZE * 2 + 1 - 1) * ChartEditorState.GRID_SIZE; + if (this.eventData.stepTime >= 0) + this.y = this.eventData.stepTime * ChartEditorState.GRID_SIZE; + + if (origin != null) + { + this.x += origin.x; + this.y += origin.y; + } + } + + /** + * Return whether this note (or its parent) is currently visible. + */ + public function isEventVisible(viewAreaBottom:Float, viewAreaTop:Float):Bool + { + var outsideViewArea = (this.y + this.height < viewAreaTop || this.y > viewAreaBottom); + + if (!outsideViewArea) + { + return true; + } + + // TODO: Check if this note's parent or child is visible. + + return false; + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx index cf1a2e018..bdf124766 100644 --- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx +++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx @@ -169,7 +169,8 @@ class ChartEditorNoteSprite extends FlxSprite if (this.noteData.stepTime >= 0) this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE; - if (origin != null) { + if (origin != null) + { this.x += origin.x; this.y += origin.y; } diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 9acaf59f0..b86aa5e88 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -1,5 +1,6 @@ package funkin.ui.debug.charting; +import haxe.DynamicAccess; import haxe.io.Path; import flixel.addons.display.FlxSliceSprite; import flixel.addons.display.FlxTiledSprite; @@ -72,3069 +73,3315 @@ using Lambda; @:allow(funkin.ui.debug.charting.ChartEditorToolboxHandler) class ChartEditorState extends HaxeUIState { - /** - * CONSTANTS - */ - // ============================== - // XML Layouts - static final CHART_EDITOR_LAYOUT = Paths.ui('chart-editor/main-view'); - - static final CHART_EDITOR_NOTIFBAR_LAYOUT = Paths.ui('chart-editor/components/notifbar'); - static final CHART_EDITOR_PLAYBARHEAD_LAYOUT = Paths.ui('chart-editor/components/playbar-head'); - - static final CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT = Paths.ui('chart-editor/toolbox/tools'); - static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT = Paths.ui('chart-editor/toolbox/notedata'); - static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT = Paths.ui('chart-editor/toolbox/eventdata'); - static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT = Paths.ui('chart-editor/toolbox/metadata'); - static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT = Paths.ui('chart-editor/toolbox/difficulty'); - static final CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT = Paths.ui('chart-editor/toolbox/characters'); - static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/player-preview'); - static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/opponent-preview'); - - // The base grid size for the chart editor. - public static final GRID_SIZE:Int = 40; - - // Number of notes in each strumline. - public static final STRUMLINE_SIZE = 4; - - // The height of the menu bar in the layout. - static final MENU_BAR_HEIGHT = 32; - - /** - * Duration to wait before autosaving the chart. - */ - static final AUTOSAVE_TIMER_DELAY:Float = 60.0 * 5.0; - - // The amount of padding between the menu bar and the chart grid when fully scrolled up. - static final GRID_TOP_PAD:Int = 8; - - public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12; - public static final PLAYHEAD_HEIGHT:Int = Std.int(GRID_SIZE / 8); - - public static final GRID_SELECTION_BORDER_WIDTH:Int = 6; - - // Duration until notifications are automatically hidden. - static final NOTIFICATION_DISMISS_TIME:Float = 3.0; - - // Start performing rapid undo after this many seconds. - static final RAPID_UNDO_DELAY:Float = 0.4; - // Perform a rapid undo every this many seconds. - static final RAPID_UNDO_INTERVAL:Float = 0.1; - - // UI Element Colors - // Background color tint. - static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF; - static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030; - static final PLAYHEAD_SCROLL_AREA_COLOR:FlxColor = 0xFF682B2F; - static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000; - static final PLAYHEAD_COLOR:FlxColor = 0xC0BD0231; - - /** - * How many pixels far the user needs to move the mouse before the cursor is considered to be dragged rather than clicked. - */ - static final DRAG_THRESHOLD:Float = 16.0; - - /** - * Types of notes you can snap to. - */ - static final SNAP_QUANTS:Array = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192]; - - /** - * INSTANCE DATA - */ - // ============================== - public var currentZoomLevel:Float = 1.0; - - var noteSnapQuantIndex:Int = 3; - - public var noteSnapQuant(get, never):Int; - - function get_noteSnapQuant():Int - { - return SNAP_QUANTS[noteSnapQuantIndex]; - } - - /** - * scrollPosition is the current position in the song, in pixels. - * One pixel is 1/40 of 1 step, and 1/160 of 1 beat. - */ - var scrollPositionInPixels(default, set):Float = -1.0; - - /** - * scrollPosition, converted to steps. - * TODO: Handle BPM changes. - */ - var scrollPositionInSteps(get, null):Float; - - function get_scrollPositionInSteps():Float - { - return scrollPositionInPixels / GRID_SIZE; - } - - /** - * scrollPosition, converted to milliseconds. - * TODO: Handle BPM changes. - */ - var scrollPositionInMs(get, set):Float; - - function get_scrollPositionInMs():Float - { - return scrollPositionInSteps * Conductor.stepCrochet; - } - - function set_scrollPositionInMs(value:Float):Float - { - scrollPositionInPixels = value / Conductor.stepCrochet; - return value; - } - - /** - * The position of the playhead, in pixels, relative to the scrollPosition. - * 0 means playhead is at the top of the grid. - * 40 means the playhead is 1 grid length below the base position. - * -40 means the playhead is 1 grid length above the base position. - */ - var playheadPositionInPixels(default, set):Float; - - var playheadPositionInSteps(get, null):Float; - - /** - * playheadPosition, converted to steps. - */ - function get_playheadPositionInSteps():Float - { - return playheadPositionInPixels / GRID_SIZE; - } - - /** - * playheadPosition, converted to milliseconds. - */ - var playheadPositionInMs(get, null):Float; - - function get_playheadPositionInMs():Float - { - return playheadPositionInSteps * Conductor.stepCrochet; - } - - /** - * This is the song's length in PIXELS, same format as scrollPosition. - */ - var songLengthInPixels(get, default):Int; - - function get_songLengthInPixels():Int - { - if (songLengthInPixels <= 0) - return 1000; - - return songLengthInPixels; - } - - /** - * songLength, converted to steps. - * TODO: Handle BPM changes. - */ - var songLengthInSteps(get, set):Float; - - function get_songLengthInSteps():Float - { - return songLengthInPixels / GRID_SIZE; - } - - function set_songLengthInSteps(value:Float):Float - { - songLengthInPixels = Std.int(value * GRID_SIZE); - return value; - } - - /** - * songLength, converted to milliseconds. - * TODO: Handle BPM changes. - */ - var songLengthInMs(get, set):Float; - - function get_songLengthInMs():Float - { - return songLengthInSteps * Conductor.stepCrochet; - } - - function set_songLengthInMs(value:Float):Float - { - songLengthInSteps = Conductor.getTimeInSteps(audioInstTrack.length); - return value; - } - - var currentTheme(default, set):ChartEditorTheme = null; - - function set_currentTheme(value:ChartEditorTheme):ChartEditorTheme - { - if (value == null || value == currentTheme) - return currentTheme; - - currentTheme = value; - ChartEditorThemeHandler.updateTheme(this); - return value; - } - - /** - * Whether a skip button has been pressed on the playbar, and which one. - * This will be used to update the scrollPosition (in the same function that handles the scroll wheel), then cleared. - */ - var playbarButtonPressed:String = null; - - /** - * Whether the head of the playbar is currently being dragged with the mouse by the user. - */ - var playbarHeadDragging:Bool = false; - - /** - * Whether music was playing before we started dragging the playbar head. - * If so, then when we stop dragging the playbar head, we should resume song playback. - */ - var playbarHeadDraggingWasPlaying:Bool = false; - - /** - * The note kind to use for notes being placed in the chart. Defaults to `''`. - */ - var selectedNoteKind:String = ''; - - /** - * Whether to play a metronome sound while the playhead is moving. - */ - var shouldPlayMetronome:Bool = true; - - /** - * Use the tool window to affect how the user interacts with the program. - */ - var currentToolMode:ChartEditorToolMode = ChartEditorToolMode.Select; - - /** - * The character sprite in the Player Preview window. - */ - var currentPlayerCharacterPlayer:CharacterPlayer = null; - - /** - * The character sprite in the Opponent Preview window. - */ - var currentOpponentCharacterPlayer:CharacterPlayer = null; - - /** - * Whether the current view is in downscroll mode. - */ - var isViewDownscroll(default, set):Bool = false; - - function set_isViewDownscroll(value:Bool):Bool - { - isViewDownscroll = value; - - // Make sure view is updated when we change view modes. - noteDisplayDirty = true; - notePreviewDirty = true; - this.scrollPositionInPixels = this.scrollPositionInPixels; - - return isViewDownscroll; - } - - /** - * Whether hitsounds are enabled for at least one character. - */ - var hitsoundsEnabled(get, null):Bool; - - function get_hitsoundsEnabled():Bool - { - return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent; - } - - /** - * Whether hitsounds are enabled for the player. - */ - var hitsoundsEnabledPlayer:Bool = true; - - /** - * Whether hitsounds are enabled for the opponent. - */ - var hitsoundsEnabledOpponent:Bool = true; - - /** - * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI. - * If so, ignore mouse events underneath. - */ - var isCursorOverHaxeUI(get, null):Bool; - - function get_isCursorOverHaxeUI():Bool - { - return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY); - } - - var isCursorOverHaxeUIButton(get, null):Bool; - - function get_isCursorOverHaxeUIButton():Bool - { - return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Button) - || Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Link); - } - - /** - * Set by ChartEditorDialogHandler, used to prevent background interaction while the dialog is open. - */ - public var isHaxeUIDialogOpen:Bool = false; - - /** - * The variation ID for the difficulty which is currently being edited. - */ - var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION; - - function set_selectedVariation(value:String):String - { - selectedVariation = value; - - // Make sure view is updated when the variation changes. - noteDisplayDirty = true; - notePreviewDirty = true; - - return selectedVariation; - } - - /** - * The difficulty ID for the difficulty which is currently being edited. - */ - var selectedDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY; - - function set_selectedDifficulty(value:String):String - { - selectedDifficulty = value; - - // Make sure view is updated when the difficulty changes. - noteDisplayDirty = true; - notePreviewDirty = true; - - return selectedDifficulty; - } - - /** - * Whether the user is currently in Pattern Mode. - * This overrides the chart editor's normal behavior. - */ - var isInPatternMode(default, set):Bool = false; - - function set_isInPatternMode(value:Bool):Bool - { - isInPatternMode = value; - - // Make sure view is updated when we change modes. - noteDisplayDirty = true; - notePreviewDirty = true; - this.scrollPositionInPixels = 0; - - return isInPatternMode; - } - - var currentPattern:String = ''; - - /** - * Whether the note display render group has been modified and needs to be updated. - * This happens when we scroll or add/remove notes, and need to update what notes are displayed and where. - */ - var noteDisplayDirty:Bool = true; - - /** - * Whether the note preview graphic needs to be FULLY rebuilt. - * The Bitmap can be modified by individual commands without using this. - */ - var notePreviewDirty:Bool = true; - - /** - * Whether the chart has been modified since it was last saved. - * Used to determine whether to auto-save, etc. - */ - var saveDataDirty(default, set):Bool = false; - - function set_saveDataDirty(value:Bool):Bool - { - if (value == saveDataDirty) - return value; - - if (value) - { - // Start the auto-save timer. - autoSaveTimer = new FlxTimer().start(AUTOSAVE_TIMER_DELAY, (_) -> autoSave()); - } - else - { - // Stop the auto-save timer. - autoSaveTimer.cancel(); - autoSaveTimer.destroy(); - autoSaveTimer = null; - } - - return saveDataDirty = value; - } - - /** - * A timer used to auto-save the chart after a period of inactivity. - */ - var autoSaveTimer:FlxTimer; - - /** - * Whether the difficulty tree view in the toolbox has been modified and needs to be updated. - * This happens when we add/remove difficulties. - */ - var difficultySelectDirty:Bool = true; - - /** - * Whether the character select view in the toolbox has been modified and needs to be updated. - * This happens when we add/remove characters. - */ - var characterSelectDirty:Bool = true; - - var isInPlaytestMode:Bool = false; - - /** - * The list of command previously performed. Used for undoing previous actions. - */ - var undoHistory:Array = []; - - /** - * The list of commands that have been undone. Used for redoing previous actions. - */ - var redoHistory:Array = []; - - var undoHeldTime:Float = 0.0; - - var redoHeldTime:Float = 0.0; - - /** - * Whether the undo/redo histories have changed since the last time the UI was updated. - */ - var commandHistoryDirty:Bool = true; - - /** - * The notes which are currently in the selection. - */ - var currentSelection:Array = []; - - /** - * The position where the user clicked to start a selection. - * The selection box extends from this point to the current mouse position. - */ - var selectionBoxStartPos:FlxPoint = null; - - /** - * Whether the user's last mouse click was on the playhead scroll area. - */ - var gridPlayheadScrollAreaPressed:Bool = false; - - /** - * The SongNoteData which is currently being placed. - * As the user drags, we will update this note's sustain length. - */ - var currentPlaceNoteData:SongNoteData = null; - - /** - * The Dialog components representing the currently available tool windows. - * Dialogs are retained here even when collapsed or hidden. - */ - var activeToolboxes:Map = new Map(); - - /** - * AUDIO AND SOUND DATA - */ - // ============================== - - /** - * The audio track for the instrumental. - */ - var audioInstTrack:FlxSound; - - /** - * The audio track for the vocals. - */ - var audioVocalTrackGroup:VocalGroup; - - /** - * A map of the audio tracks for each character's vocals. - * - Keys are the character IDs. - * - Values are the FlxSound objects to play that character's vocals. - * - * When switching characters, the elements of the VocalGroup will be swapped to match the new character. - */ - var audioVocalTracks:Map = new Map(); - - /** - * CHART DATA - */ - // ============================== - - /** - * The song metadata. - * - Keys are the variation IDs. At least one (`default`) must exist. - * - Values are the relevant metadata, ready to be serialized to JSON. - */ - var songMetadata:Map; - - var availableVariations(get, null):Array; - - function get_availableVariations():Array - { - return [for (x in songMetadata.keys()) x]; - } - - /** - * The song chart data. - * - Keys are the variation IDs. At least one (`default`) must exist. - * - Values are the relevant chart data, ready to be serialized to JSON. - */ - var songChartData:Map; - - /** - * Convenience property to get the chart data for the current variation. - */ - var currentSongMetadata(get, set):SongMetadata; - - function get_currentSongMetadata():SongMetadata - { - var result = songMetadata.get(selectedVariation); - if (result == null) - { - result = new SongMetadata('Dad Battle', 'Kawai Sprite', selectedVariation); - songMetadata.set(selectedVariation, result); - } - return result; - } - - function set_currentSongMetadata(value:SongMetadata):SongMetadata - { - songMetadata.set(selectedVariation, value); - return value; - } - - /** - * Convenience property to get the chart data for the current variation. - */ - var currentSongChartData(get, set):SongChartData; - - function get_currentSongChartData():SongChartData - { - var result = songChartData.get(selectedVariation); - if (result == null) - { - result = new SongChartData(1.0, [], []); - songChartData.set(selectedVariation, result); - } - return result; - } - - function set_currentSongChartData(value:SongChartData):SongChartData - { - songChartData.set(selectedVariation, value); - return value; - } - - /** - * Convenience property to get (and set) the scroll speed for the current difficulty. - */ - var currentSongChartScrollSpeed(get, set):Float; - - function get_currentSongChartScrollSpeed():Float - { - var result = currentSongChartData.scrollSpeed.get(selectedDifficulty); - if (result == null) - { - // Initialize to the default value if not set. - currentSongChartData.scrollSpeed.set(selectedDifficulty, 1.0); - return 1.0; - } - return result; - } - - function set_currentSongChartScrollSpeed(value:Float):Float - { - currentSongChartData.scrollSpeed.set(selectedDifficulty, value); - return value; - } - - /** - * Convenience property to get the note data for the current difficulty. - */ - var currentSongChartNoteData(get, set):Array; - - function get_currentSongChartNoteData():Array - { - var result = currentSongChartData.notes.get(selectedDifficulty); - if (result == null) - { - // Initialize to the default value if not set. - result = []; - currentSongChartData.notes.set(selectedDifficulty, result); - return result; - } - return result; - } - - function set_currentSongChartNoteData(value:Array):Array - { - currentSongChartData.notes.set(selectedDifficulty, value); - return value; - } - - /** - * Convenience property to get the event data for the current difficulty. - */ - var currentSongChartEventData(get, set):Array; - - function get_currentSongChartEventData():Array - { - if (currentSongChartData.events == null) - { - // Initialize to the default value if not set. - currentSongChartData.events = []; - } - return currentSongChartData.events; - } - - function set_currentSongChartEventData(value:Array):Array - { - currentSongChartData.events = value; - return value; - } - - public var currentSongNoteSkin(get, set):String; - - function get_currentSongNoteSkin():String - { - if (currentSongMetadata.playData.noteSkin == null) - { - // Initialize to the default value if not set. - currentSongMetadata.playData.noteSkin = 'Normal'; - } - return currentSongMetadata.playData.noteSkin; - } - - function set_currentSongNoteSkin(value:String):String - { - return currentSongMetadata.playData.noteSkin = value; - } - - var currentSongStage(get, set):String; - - function get_currentSongStage():String - { - if (currentSongMetadata.playData.stage == null) - { - // Initialize to the default value if not set. - currentSongMetadata.playData.stage = 'mainStage'; - } - return currentSongMetadata.playData.stage; - } - - function set_currentSongStage(value:String):String - { - return currentSongMetadata.playData.stage = value; - } - - var currentSongName(get, set):String; - - function get_currentSongName():String - { - if (currentSongMetadata.songName == null) - { - // Initialize to the default value if not set. - currentSongMetadata.songName = 'New Song'; - } - return currentSongMetadata.songName; - } - - function set_currentSongName(value:String):String - { - return currentSongMetadata.songName = value; - } - - var currentSongId(get, null):String; - - function get_currentSongId():String - { - return currentSongName.toLowerKebabCase(); - } - - var currentSongArtist(get, set):String; - - function get_currentSongArtist():String - { - if (currentSongMetadata.artist == null) - { - // Initialize to the default value if not set. - currentSongMetadata.artist = 'Unknown'; - } - return currentSongMetadata.artist; - } - - function set_currentSongArtist(value:String):String - { - return currentSongMetadata.artist = value; - } - - /** - * RENDER OBJECTS - */ - // ============================== - - /** - * The IMAGE used for the grid. Updated by ChartEditorThemeHandler. - */ - var gridBitmap:BitmapData; - - /** - * The IMAGE used for the selection squares. Updated by ChartEditorThemeHandler. - * Used two ways: - * 1. A sprite is given this bitmap and placed over selected notes. - * 2. The image is split and used for a 9-slice sprite for the selection box. - */ - var selectionSquareBitmap:BitmapData = null; - - /** - * The tiled sprite used to display the grid. - * The height is the length of the song, and scrolling is done by simply the sprite. - */ - var gridTiledSprite:FlxSprite; - - /** - * The playhead representing the current position in the song. - * Can move around on the grid independently of the view. - */ - var gridPlayhead:FlxSpriteGroup; - - var gridPlayheadScrollArea:FlxSprite; - - /** - * A sprite used to indicate the note that will be placed on click. - */ - var gridGhostNote:ChartEditorNoteSprite; - - /** - * The waveform which (optionally) displays over the grid, underneath the notes and playhead. - */ - var gridSpectrogram:PolygonSpectogram; - - /** - * The rectangle used for the note preview area. - * Should span the full height of the song. We scribble on this to draw the preview. - */ - var notePreviewBitmap:BitmapData; - - /** - * The sprite used to display the note preview area. - * We move this up and down to scroll the preview. - */ - var notePreviewSprite:FlxSprite; - - /** - * The rectangular sprite used for rendering the selection box. - * Uses a 9-slice to stretch the selection box to the correct size without warping. - */ - var selectionBoxSprite:FlxSliceSprite; - - /** - * The opponent's health icon. - */ - var healthIconDad:HealthIcon; - - /** - * The player's health icon. - */ - var healthIconBF:HealthIcon; - - /** - * The purple background sprite. - */ - var menuBG:FlxSprite; - - /** - * The sprite group containing the note graphics. - * Only displays a subset of the data from `currentSongChartNoteData`, - * and kills notes that are off-screen to be recycled later. - */ - var renderedNotes:FlxTypedSpriteGroup; - - var renderedNoteSelectionSquares:FlxTypedSpriteGroup; - - var notifBar:SideBar; - var playbarHead:Slider; - - public function new() - { - // Load the HaxeUI XML file. - super(CHART_EDITOR_LAYOUT); - } - - override function create() - { - // Get rid of any music from the previous state. - FlxG.sound.music.stop(); - - buildDefaultSongData(); - - buildBackground(); - - currentTheme = ChartEditorTheme.Light; - - buildGrid(); - buildSelectionBox(); - - // Add the HaxeUI components after the grid so they're on top. - super.create(); - buildAdditionalUI(); - - // Setup the onClick listeners for the UI after it's been created. - setupUIListeners(); - - setupAutoSave(); - - // TODO: We should be loading the music later when the user requests it. - // loadDefaultMusic(); - - // TODO: Change to false. - var canCloseInitialDialog = true; - ChartEditorDialogHandler.openWelcomeDialog(this, canCloseInitialDialog); - } - - function buildDefaultSongData() - { - selectedVariation = Constants.DEFAULT_VARIATION; - selectedDifficulty = Constants.DEFAULT_DIFFICULTY; - - // Initialize the song metadata. - songMetadata = new Map(); - - // Initialize the song chart data. - songChartData = new Map(); - - audioVocalTrackGroup = new VocalGroup(); - } - - /** - * Builds and displays the background sprite. - */ - function buildBackground() - { - menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); - add(menuBG); - - menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); - menuBG.updateHitbox(); - menuBG.screenCenter(); - menuBG.scrollFactor.set(0, 0); - } - - /** - * Builds and displays the chart editor grid, including the playhead and cursor. - */ - function buildGrid() - { - gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true); - gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid. - gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar. - add(gridTiledSprite); - - gridGhostNote = new ChartEditorNoteSprite(this); - gridGhostNote.alpha = 0.6; - gridGhostNote.noteData = new SongNoteData(-1, -1, 0, ""); - gridGhostNote.visible = false; - add(gridGhostNote); - - buildNoteGroup(); - - gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH, - MENU_BAR_HEIGHT).makeGraphic(PLAYHEAD_SCROLL_AREA_WIDTH, FlxG.height - MENU_BAR_HEIGHT, PLAYHEAD_SCROLL_AREA_COLOR); - add(gridPlayheadScrollArea); - - // The playhead that show the current position in the song. - gridPlayhead = new FlxSpriteGroup(); - add(gridPlayhead); - - var playheadWidth = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2); - var playheadBaseYPos = MENU_BAR_HEIGHT + GRID_TOP_PAD; - gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos); - var playheadSprite = new FlxSprite().makeGraphic(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR); - playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH; - playheadSprite.y = 0; - gridPlayhead.add(playheadSprite); - - var playheadBlock = ChartEditorThemeHandler.buildPlayheadBlock(); - playheadBlock.x = -PLAYHEAD_SCROLL_AREA_WIDTH; - playheadBlock.y = -PLAYHEAD_HEIGHT / 2; - gridPlayhead.add(playheadBlock); - - // Character icons. - healthIconDad = new HealthIcon('dad'); - healthIconDad.autoUpdate = false; - healthIconDad.size.set(0.5, 0.5); - healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5); - healthIconDad.y = gridTiledSprite.y + 5; - add(healthIconDad); - - healthIconBF = new HealthIcon('bf'); - healthIconBF.autoUpdate = false; - healthIconBF.size.set(0.5, 0.5); - healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; - healthIconBF.y = gridTiledSprite.y + 5; - healthIconBF.flipX = true; - add(healthIconBF); - } - - function buildSelectionBox() - { - selectionBoxSprite.scrollFactor.set(0, 0); - add(selectionBoxSprite); - - setSelectionBoxBounds(); - } - - function setSelectionBoxBounds(?bounds:FlxRect = null) - { - if (bounds == null) - { - selectionBoxSprite.visible = false; - selectionBoxSprite.x = -9999; - selectionBoxSprite.y = -9999; - } - else - { - selectionBoxSprite.visible = true; - selectionBoxSprite.x = bounds.x; - selectionBoxSprite.y = bounds.y; - selectionBoxSprite.width = bounds.width; - selectionBoxSprite.height = bounds.height; - } - } - - function buildSpectrogram(target:FlxSound) - { - gridSpectrogram = new PolygonSpectogram(target, SPECTROGRAM_COLOR, FlxG.height / 2, Math.floor(FlxG.height / 2)); - // Halfway through the grid. - // gridSpectrogram.x = gridTiledSprite.x + STRUMLINE_SIZE * GRID_SIZE; - // gridSpectrogram.y = gridTiledSprite.y; - gridSpectrogram.x = 200; - gridSpectrogram.y = 200; - gridSpectrogram.visType = STATIC; // We move the spectrogram manually. - gridSpectrogram.waveAmplitude = 50; - gridSpectrogram.scrollFactor.set(0, 0); - add(gridSpectrogram); - } - - /** - * Builds the group that will hold all the notes. - */ - function buildNoteGroup() - { - renderedNotes = new FlxTypedSpriteGroup(); - renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); - add(renderedNotes); - - renderedNoteSelectionSquares = new FlxTypedSpriteGroup(); - renderedNoteSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y); - add(renderedNoteSelectionSquares); - - /* - var sustainSprite:SustainTrail = new SustainTrail(0, 600, Paths.image('NOTE_hold_assets'), 0.9, false); - sustainSprite.scrollFactor.set(0, 0); - sustainSprite.x = gridTiledSprite.x; - sustainSprite.y = gridTiledSprite.y + 32; - sustainSprite.zoom *= 0.258; // 0.77; - add(sustainSprite); - */ - } - - var playbarHeadLayout:Component; - - function buildAdditionalUI():Void - { - notifBar = cast buildComponent(CHART_EDITOR_NOTIFBAR_LAYOUT); - - add(notifBar); - - playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT); - - playbarHeadLayout.width = FlxG.width - 8; - playbarHeadLayout.height = 10; - playbarHeadLayout.x = 4; - playbarHeadLayout.y = FlxG.height - 48 - 8; - - playbarHead = playbarHeadLayout.findComponent('playbarHead', Slider); - playbarHead.allowFocus = false; - playbarHead.width = FlxG.width; - playbarHead.height = 10; - playbarHead.styleString = "padding-left: 0px; padding-right: 0px; border-left: 0px; border-right: 0px;"; - - playbarHead.onDragStart = function(_:DragEvent) - { - playbarHeadDragging = true; - - // If we were dragging the playhead while the song was playing, resume playing. - if (audioInstTrack != null && audioInstTrack.playing) - { - playbarHeadDraggingWasPlaying = true; - stopAudioPlayback(); - } - else - { - playbarHeadDraggingWasPlaying = false; - } - } - - playbarHead.onDragEnd = function(_:DragEvent) - { - playbarHeadDragging = false; - - // Set the song position to where the playhead was moved to. - scrollPositionInPixels = songLengthInPixels * (playbarHead.value / 100); - // Update the conductor and audio tracks to match. - moveSongToScrollPosition(); - - // If we were dragging the playhead while the song was playing, resume playing. - if (playbarHeadDraggingWasPlaying) - { - playbarHeadDraggingWasPlaying = false; - startAudioPlayback(); - } - } - - add(playbarHeadLayout); - } - - /** - * Sets up the onClick listeners for the UI. - */ - function setupUIListeners():Void - { - // Add functionality to the playbar. - - addUIClickListener('playbarPlay', (event:MouseEvent) -> toggleAudioPlayback()); - addUIClickListener('playbarStart', (event:MouseEvent) -> playbarButtonPressed = 'playbarStart'); - addUIClickListener('playbarBack', (event:MouseEvent) -> playbarButtonPressed = 'playbarBack'); - addUIClickListener('playbarForward', (event:MouseEvent) -> playbarButtonPressed = 'playbarForward'); - addUIClickListener('playbarEnd', (event:MouseEvent) -> playbarButtonPressed = 'playbarEnd'); - - // Add functionality to the menu items. - - addUIClickListener('menubarItemNewChart', (event:MouseEvent) -> ChartEditorDialogHandler.openWelcomeDialog(this, true)); - addUIClickListener('menubarItemSaveChartAs', (event:MouseEvent) -> exportAllSongData()); - addUIClickListener('menubarItemLoadInst', (event:MouseEvent) -> ChartEditorDialogHandler.openUploadInstDialog(this, true)); - - addUIClickListener('menubarItemUndo', (event:MouseEvent) -> undoLastCommand()); - - addUIClickListener('menubarItemRedo', (event:MouseEvent) -> redoLastCommand()); - - addUIClickListener('menubarItemCopy', (event:MouseEvent) -> - { - SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(currentSelection)); - }); - - addUIClickListener('menubarItemCut', (event:MouseEvent) -> - { - performCommand(new CutNotesCommand(currentSelection)); - }); - - addUIClickListener('menubarItemPaste', (event:MouseEvent) -> - { - performCommand(new PasteNotesCommand(scrollPositionInMs + playheadPositionInMs)); - }); - - addUIClickListener('menubarItemDelete', (event:MouseEvent) -> - { - performCommand(new RemoveNotesCommand(currentSelection)); - }); - - addUIClickListener('menubarItemSelectAll', (event:MouseEvent) -> - { - performCommand(new SelectAllNotesCommand(currentSelection)); - }); - - addUIClickListener('menubarItemSelectInverse', (event:MouseEvent) -> - { - performCommand(new InvertSelectedNotesCommand(currentSelection)); - }); - - addUIClickListener('menubarItemSelectNone', (event:MouseEvent) -> - { - performCommand(new DeselectAllNotesCommand(currentSelection)); - }); - - addUIClickListener('menubarItemSelectRegion', (event:MouseEvent) -> { - // TODO: Implement this. - }); - - addUIClickListener('menubarItemSelectBeforeCursor', (event:MouseEvent) -> { - // TODO: Implement this. - }); - - addUIClickListener('menubarItemSelectAfterCursor', (event:MouseEvent) -> { - // TODO: Implement this. - }); - - addUIClickListener('menubarItemAbout', (event:MouseEvent) -> ChartEditorDialogHandler.openAboutDialog(this)); - - addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> ChartEditorDialogHandler.openUserGuideDialog(this)); - - addUIChangeListener('menubarItemDownscroll', (event:UIEvent) -> - { - isViewDownscroll = event.value; - }); - setUICheckboxSelected('menubarItemDownscroll', isViewDownscroll); - - addUIChangeListener('menuBarItemThemeLight', (event:UIEvent) -> - { - if (event.target.value) - currentTheme = ChartEditorTheme.Light; - }); - setUICheckboxSelected('menuBarItemThemeLight', currentTheme == ChartEditorTheme.Light); - - addUIChangeListener('menuBarItemThemeDark', (event:UIEvent) -> - { - if (event.target.value) - currentTheme = ChartEditorTheme.Dark; - }); - setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark); - - addUIChangeListener('menubarItemMetronomeEnabled', (event:UIEvent) -> - { - shouldPlayMetronome = event.value; - }); - setUICheckboxSelected('menubarItemMetronomeEnabled', shouldPlayMetronome); - - addUIChangeListener('menubarItemPlayerHitsounds', (event:UIEvent) -> - { - hitsoundsEnabledPlayer = event.value; - }); - setUICheckboxSelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer); - - addUIChangeListener('menubarItemOpponentHitsounds', (event:UIEvent) -> - { - hitsoundsEnabledOpponent = event.value; - }); - setUICheckboxSelected('menubarItemOpponentHitsounds', hitsoundsEnabledOpponent); - - var instVolumeLabel:Label = findComponent('menubarLabelVolumeInstrumental', Label); - addUIChangeListener('menubarItemVolumeInstrumental', (event:UIEvent) -> - { - var volume:Float = event.value / 100.0; - if (audioInstTrack != null) - audioInstTrack.volume = volume; - instVolumeLabel.text = 'Instrumental - ${Std.int(event.value)}%'; - }); - - var vocalsVolumeLabel:Label = findComponent('menubarLabelVolumeVocals', Label); - addUIChangeListener('menubarItemVolumeVocals', (event:UIEvent) -> - { - var volume:Float = event.value / 100.0; - if (audioVocalTrackGroup != null) - audioVocalTrackGroup.volume = volume; - vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%'; - }); - - var playbackSpeedLabel:Label = findComponent('menubarLabelPlaybackSpeed', Label); - addUIChangeListener('menubarItemPlaybackSpeed', (event:UIEvent) -> - { - var pitch = event.value * 2.0 / 100.0; - #if FLX_PITCH - if (audioInstTrack != null) - audioInstTrack.pitch = pitch; - if (audioVocalTrackGroup != null) - audioVocalTrackGroup.pitch = pitch; - #end - playbackSpeedLabel.text = 'Playback Speed - ${Std.int(event.value * 100) / 100}x'; - }); - - addUIChangeListener('menubarItemToggleToolboxTools', (event:UIEvent) -> - { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT, event.value); - }); - // setUICheckboxSelected('menubarItemToggleToolboxTools', true); - addUIChangeListener('menubarItemToggleToolboxNotes', (event:UIEvent) -> - { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value); - }); - addUIChangeListener('menubarItemToggleToolboxEvents', (event:UIEvent) -> - { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value); - }); - addUIChangeListener('menubarItemToggleToolboxDifficulty', (event:UIEvent) -> - { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value); - }); - addUIChangeListener('menubarItemToggleToolboxMetadata', (event:UIEvent) -> - { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value); - }); - addUIChangeListener('menubarItemToggleToolboxCharacters', (event:UIEvent) -> - { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT, event.value); - }); - addUIChangeListener('menubarItemToggleToolboxPlayerPreview', (event:UIEvent) -> - { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value); - }); - addUIChangeListener('menubarItemToggleToolboxOpponentPreview', (event:UIEvent) -> - { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value); - }); - - // TODO: Pass specific HaxeUI components to add context menus to them. - registerContextMenu(null, Paths.ui('chart-editor/context/test')); - } - - /** - * Setup timers and listerners to handle auto-save. - */ - function setupAutoSave() - { - WindowUtil.windowExit.add(onWindowClose); - saveDataDirty = false; - } - - /** - * Called after 5 minutes without saving. - */ - function autoSave() - { - saveDataDirty = false; - - // Auto-save the chart. - - #if html5 - // Auto-save to local storage. - #else - // Auto-save to temp file. - exportAllSongData(true, true); - #end - } - - function onWindowClose(exitCode:Int) - { - trace('Window exited with exit code: $exitCode'); - trace('Should save chart? $saveDataDirty'); - - if (saveDataDirty) - { - exportAllSongData(true); - } - } - - function cleanupAutoSave() - { - WindowUtil.windowExit.remove(onWindowClose); - } - - public override function update(elapsed:Float) - { - // dispatchEvent gets called here. - super.update(elapsed); - - FlxG.mouse.visible = true; - - // These ones happen even if the modal dialog is open. - handleMusicPlayback(); - handleNoteDisplay(); - - // These ones only happen if the modal dialog is not open. - handleScrollKeybinds(); - // handleZoom(); - // handleSnap(); - handleCursor(); - - handleMenubar(); - handleToolboxes(); - handlePlaybar(); - handlePlayhead(); - - handleFileKeybinds(); - handleEditKeybinds(); - handleViewKeybinds(); - handleHelpKeybinds(); - - // DEBUG - #if debug - if (FlxG.keys.justPressed.F) - { - // This breaks the layout don't use it. - // showNotification('Hi there :)'); - - autoSave(); - } - - if (FlxG.keys.justPressed.E) - { - currentSongMetadata.timeChanges[0].timeSignatureNum = (currentSongMetadata.timeChanges[0].timeSignatureNum == 4 ? 3 : 4); - } - #end - - // Right align the BF health icon. - - // Base X position to the right of the grid. - var baseHealthIconXPos = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; - // Will be 0 when not bopping. When bopping, will increase to push the icon left. - var healthIconOffset = healthIconBF.width - (HealthIcon.HEALTH_ICON_SIZE * 0.5); - healthIconBF.x = baseHealthIconXPos - healthIconOffset; - } - - /** - * Beat hit while the song is playing. - */ - override function beatHit():Bool - { - // dispatchEvent gets called here. - if (!super.beatHit()) - return false; - - if (shouldPlayMetronome && audioInstTrack.playing) - { - playMetronomeTick(Conductor.currentBeat % 4 == 0); - } - - return true; - } - - /** - * Step hit while the song is playing. - */ - override function stepHit():Bool - { - // dispatchEvent gets called here. - if (!super.stepHit()) - return false; - - if (audioInstTrack.playing) - { - healthIconDad.onStepHit(Conductor.currentStep); - healthIconBF.onStepHit(Conductor.currentStep); - } - - // if (shouldPlayMetronome) - // playMetronomeTick(false); - - return true; - } - - /** - * Handle keybinds for scrolling the chart editor grid. - **/ - function handleScrollKeybinds() - { - // Don't scroll when the cursor is over the UI. - if (isCursorOverHaxeUI) - return; - - // Amount to scroll the grid. - var scrollAmount:Float = 0; - // Amount to scroll the playhead relative to the grid. - var playheadAmount:Float = 0; - - // Up Arrow = Scroll Up - if (FlxG.keys.justPressed.UP) - { - scrollAmount = -GRID_SIZE * 0.25; - } - // Down Arrow = Scroll Down - if (FlxG.keys.justPressed.DOWN) - { - scrollAmount = GRID_SIZE * 0.25; - } - - // PAGE UP = Jump Up 1 Measure - if (FlxG.keys.justPressed.PAGEUP) - { - scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure; - } - if (playbarButtonPressed == 'playbarBack') - { - playbarButtonPressed = ''; - scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure; - } - - // PAGE DOWN = Jump Down 1 Measure - if (FlxG.keys.justPressed.PAGEDOWN) - { - scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure; - } - if (playbarButtonPressed == 'playbarForward') - { - playbarButtonPressed = ''; - scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure; - } - - // Mouse Wheel = Scroll - if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL) - { - scrollAmount = -10 * FlxG.mouse.wheel; - } - - // Middle Mouse + Drag = Scroll but move the playhead the same amount. - if (FlxG.mouse.pressedMiddle) - { - if (FlxG.mouse.deltaY != 0) - { - // Scroll down by the amount dragged. - scrollAmount += -FlxG.mouse.deltaY; - // Move the playhead by the same amount in the other direction so it is stationary. - playheadAmount += FlxG.mouse.deltaY; - } - } - - // SHIFT + Scroll = Scroll Fast - if (FlxG.keys.pressed.SHIFT) - { - scrollAmount *= 5; - } - // CONTROL + Scroll = Scroll Precise - if (FlxG.keys.pressed.CONTROL) - { - scrollAmount /= 10; - } - - // ALT = Move playhead instead. - if (FlxG.keys.pressed.ALT) - { - playheadAmount = scrollAmount; - scrollAmount = 0; - } - - // HOME = Scroll to Top - if (FlxG.keys.justPressed.HOME) - { - // Scroll amount is the difference between the current position and the top. - scrollAmount = 0 - this.scrollPositionInPixels; - playheadAmount = 0 - this.playheadPositionInPixels; - } - if (playbarButtonPressed == 'playbarStart') - { - playbarButtonPressed = ''; - scrollAmount = 0 - this.scrollPositionInPixels; - playheadAmount = 0 - this.playheadPositionInPixels; - } - - // END = Scroll to Bottom - if (FlxG.keys.justPressed.END) - { - // Scroll amount is the difference between the current position and the bottom. - scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; - } - if (playbarButtonPressed == 'playbarEnd') - { - playbarButtonPressed = ''; - scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; - } - - // Apply the scroll amount. - this.scrollPositionInPixels += scrollAmount; - this.playheadPositionInPixels += playheadAmount; - - // Resync the conductor and audio tracks. - if (scrollAmount != 0 || playheadAmount != 0) - moveSongToScrollPosition(); - } - - function handleZoom() - { - if (FlxG.keys.justPressed.MINUS) - { - currentZoomLevel /= 2; - - // Update the grid. - ChartEditorThemeHandler.updateTheme(this); - // Update the note positions. - noteDisplayDirty = true; - } - - if (FlxG.keys.justPressed.PLUS) - { - currentZoomLevel *= 2; - - // Update the grid. - ChartEditorThemeHandler.updateTheme(this); - // Update the note positions. - noteDisplayDirty = true; - } - } - - function handleSnap() - { - if (FlxG.keys.justPressed.LEFT) - { - noteSnapQuantIndex--; - } - - if (FlxG.keys.justPressed.RIGHT) - { - noteSnapQuantIndex++; - } - } - - /** - * Handle display of the mouse cursor. - */ - function handleCursor() - { - // Note: If a menu is open in HaxeUI, don't handle cursor behavior. - var shouldHandleCursor = !isCursorOverHaxeUI || (selectionBoxStartPos != null); - var eventColumn = (STRUMLINE_SIZE * 2 + 1) - 1; - - if (shouldHandleCursor) - { - var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite); - - // Cursor position relative to the grid. - var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x; - var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y; - - var overlapsSelectionBorder = overlapsGrid - && (cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) - || (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)) - || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)); - - if (FlxG.mouse.justPressed) - { - if (FlxG.mouse.overlaps(gridPlayheadScrollArea)) - { - gridPlayheadScrollAreaPressed = true; - } - else if (!overlapsGrid || overlapsSelectionBorder) - { - selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY); - } - } - - if (gridPlayheadScrollAreaPressed) - { - Cursor.cursorMode = Grabbing; - } - else if (FlxG.mouse.overlaps(gridPlayheadScrollArea)) - { - Cursor.cursorMode = Pointer; - } - else - { - Cursor.cursorMode = Default; - } - - if (gridPlayheadScrollAreaPressed && FlxG.mouse.released) - { - gridPlayheadScrollAreaPressed = false; - } - - if (gridPlayheadScrollAreaPressed) - { - // Clicked on the playhead scroll area. - // Move the playhead to the cursor position. - this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD; - moveSongToScrollPosition(); - } - - // Cursor position snapped to the grid. - - // The song position of the cursor, in steps. - var cursorFractionalStep:Float = cursorY / GRID_SIZE / (16 / noteSnapQuant); - var cursorStep:Int = Std.int(Math.floor(cursorFractionalStep)); - var cursorMs:Float = cursorStep * Conductor.stepCrochet * (16 / noteSnapQuant); - // The direction value for the column at the cursor. - var cursorColumn:Int = Math.floor(cursorX / GRID_SIZE); - if (cursorColumn < 0) - cursorColumn = 0; - if (cursorColumn >= (STRUMLINE_SIZE * 2 + 1 - 1)) - { - // Don't invert the event column. - cursorColumn = (STRUMLINE_SIZE * 2 + 1 - 1); - } - else - { - // Invert player and opponent columns. - if (cursorColumn >= STRUMLINE_SIZE) - { - cursorColumn -= STRUMLINE_SIZE; - } - else - { - cursorColumn += STRUMLINE_SIZE; - } - } - - if (selectionBoxStartPos != null) - { - var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x; - var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y; - - // Determine if we moved the mouse at all. - if (Math.abs(cursorX - cursorXStart) > DRAG_THRESHOLD || Math.abs(cursorY - cursorYStart) > DRAG_THRESHOLD) - { - // Handle releasing the selection box. - if (FlxG.mouse.justReleased) - { - // We released the mouse. Select the notes in the box. - var cursorFractionalStepStart:Float = cursorYStart / GRID_SIZE; - var cursorStepStart:Int = Math.floor(cursorFractionalStepStart); - var cursorMsStart:Float = cursorStepStart * Conductor.stepCrochet; - var cursorColumnBase:Int = Math.floor(cursorX / GRID_SIZE); - var cursorColumnBaseStart:Int = Math.floor(cursorXStart / GRID_SIZE); - - // Since this selects based on noteData directly, - // we don't need to specifically exclude sustain pieces. - - var notesToSelect:Array = currentSongChartNoteData; - notesToSelect = SongDataUtils.getNotesInTimeRange(notesToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs)); - - // This logic is gross because the columns go 4567-0123-8. - // We build a list of columns to select. - var columnStart:Int = Std.int(Math.min(cursorColumnBase, cursorColumnBaseStart)); - var columnEnd:Int = Std.int(Math.max(cursorColumnBase, cursorColumnBaseStart)); - var columns:Array = [for (i in columnStart...(columnEnd + 1)) i].map(function(i:Int):Int - { - if (i >= (STRUMLINE_SIZE * 2 + 1 - 1)) - { - // Don't invert the event column. - return (STRUMLINE_SIZE * 2 + 1 - 1); - } - else if (i >= STRUMLINE_SIZE) - { - // Invert the player columns. - return i - STRUMLINE_SIZE; - } - else if (i >= 0) - { - // Invert the opponent columns. - return i + STRUMLINE_SIZE; - } - else - { - // Minimum of 0. - return 0; - } - }); - notesToSelect = SongDataUtils.getNotesWithData(notesToSelect, columns); - - if (notesToSelect != null && notesToSelect.length > 0) - { - if (FlxG.keys.pressed.CONTROL) - { - // Add to the selection. - performCommand(new SelectNotesCommand(notesToSelect)); - } - else - { - // Set the selection. - performCommand(new SetNoteSelectionCommand(notesToSelect, currentSelection)); - } - } - else - { - // We made a selection box, but it didn't select anything. - } - - // Clear the selection box. - selectionBoxStartPos = null; - setSelectionBoxBounds(); - } - else - { - // Render the selection box. - var selectionRect = new FlxRect(); - selectionRect.x = Math.min(FlxG.mouse.screenX, selectionBoxStartPos.x); - selectionRect.y = Math.min(FlxG.mouse.screenY, selectionBoxStartPos.y); - selectionRect.width = Math.abs(FlxG.mouse.screenX - selectionBoxStartPos.x); - selectionRect.height = Math.abs(FlxG.mouse.screenY - selectionBoxStartPos.y); - setSelectionBoxBounds(selectionRect); - } - } - else if (FlxG.mouse.justReleased) - { - // Clear the selection box. - selectionBoxStartPos = null; - setSelectionBoxBounds(); - - if (overlapsGrid) - { - // We clicked on the grid without moving the mouse. - - // Find the first note that is at the cursor position. - var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool - { - // If note.alive is false, the note is dead and awaiting recycling. - return note.alive && FlxG.mouse.overlaps(note); - }); - - if (FlxG.keys.pressed.CONTROL) - { - if (highlightedNote != null) - { - // Handle the case of clicking on a sustain piece. - highlightedNote = highlightedNote.getBaseNoteSprite(); - // Control click to select/deselect an individual note. - if (isNoteSelected(highlightedNote.noteData)) - { - performCommand(new DeselectNotesCommand([highlightedNote.noteData])); - } - else - { - performCommand(new SelectNotesCommand([highlightedNote.noteData])); - } - } - else - { - if (highlightedNote != null) - { - // Click to select an individual note and deselect everything else. - if (isNoteSelected(highlightedNote.noteData)) - { - performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection)); - } - else - { - // Do nothing if you control-clicked on an empty space. - } - } - } - } - else - { - if (highlightedNote != null) - { - // Click a note to select it. - performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection)); - } - else - { - // Click on an empty space to deselect everything. - // We don't place a note since this is the Select tool mode. - performCommand(new DeselectAllNotesCommand(currentSelection)); - } - } - } - else - { - // If we clicked and released outside the grid, do nothing. - } - } - } - else if (currentPlaceNoteData != null) - { - // Handle extending the note as you drag. - - // Since use Math.floor and stepCrochet here, the hold notes will be beat snapped. - var dragLengthSteps:Float = Math.floor((cursorMs - currentPlaceNoteData.time) / Conductor.stepCrochet); - - // Without this, the newly placed note feels too short compared to the user's input. - var INCREMENT:Float = 1.0; - var dragLengthMs:Float = (dragLengthSteps + INCREMENT) * Conductor.stepCrochet; - - // TODO: Add and update some sort of preview? - - if (FlxG.mouse.justReleased) - { - if (dragLengthSteps > 0) - { - // Apply the new length. - performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs)); - } - - // Finished dragging. Release the note. - currentPlaceNoteData = null; - } - } - else - { - if (FlxG.mouse.justPressed) - { - // Just clicked to place a note. - if (overlapsGrid && !overlapsSelectionBorder) - { - // We clicked on the grid without moving the mouse. - - // Find the first note that is at the cursor position. - var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool - { - // If note.alive is false, the note is dead and awaiting recycling. - return note.alive && FlxG.mouse.overlaps(note); - }); - - if (FlxG.keys.pressed.CONTROL) - { - // Control click to select/deselect an individual note. - if (isNoteSelected(highlightedNote.noteData)) - { - performCommand(new DeselectNotesCommand([highlightedNote.noteData])); - } - else - { - performCommand(new SelectNotesCommand([highlightedNote.noteData])); - } - } - else - { - if (highlightedNote != null) - { - // Click a note to select it. - performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection)); - } - else - { - // Click a blank space to place a note and select it. - - if (cursorColumn == eventColumn) - { - // Create an event and place it in the chart. - // TODO: Allow configuring the event to place. - var newEventData:SongEventData = new SongEventData(cursorMs, "test", {}); - - performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL)); - } - else - { - // Create a note and place it in the chart. - var newNoteData:SongNoteData = new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind); - - performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); - - currentPlaceNoteData = newNoteData; - } - } - } - } - else - { - // If we clicked and released outside the grid, do nothing. - } - } - - var rightMouseUpdated:Bool = (FlxG.mouse.justPressedRight) - || (FlxG.mouse.pressedRight && (FlxG.mouse.deltaX > 0 || FlxG.mouse.deltaY > 0)); - if (rightMouseUpdated && overlapsGrid) - { - // We right clicked on the grid. - - // Find the first note that is at the cursor position. - var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool - { - // If note.alive is false, the note is dead and awaiting recycling. - return note.alive && FlxG.mouse.overlaps(note); - }); - - if (highlightedNote != null) - { - // Handle the case of clicking on a sustain piece. - highlightedNote = highlightedNote.getBaseNoteSprite(); - // Remove the note. - performCommand(new RemoveNotesCommand([highlightedNote.noteData])); - } - } - - // Handle grid cursor. - if (overlapsGrid && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed) - { - Cursor.cursorMode = Pointer; - - // Indicate that we can pla - gridGhostNote.visible = (cursorColumn != eventColumn); - - if (cursorColumn != gridGhostNote.noteData.data || selectedNoteKind != gridGhostNote.noteData.kind) - { - gridGhostNote.noteData.kind = selectedNoteKind; - gridGhostNote.noteData.data = cursorColumn; - gridGhostNote.playNoteAnimation(); - } - - gridGhostNote.noteData.time = cursorMs; - gridGhostNote.updateNotePosition(renderedNotes); - - // gridCursor.visible = true; - // // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square. - // gridCursor.x = Math.floor(cursorX / GRID_SIZE) * GRID_SIZE + gridTiledSprite.x + (GRID_SELECTION_BORDER_WIDTH / 2); - // gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y + (GRID_SELECTION_BORDER_WIDTH / 2); - } - else - { - gridGhostNote.visible = false; - Cursor.cursorMode = Default; - } - } - } - else - { - gridGhostNote.visible = false; - } - - if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default) - { - Cursor.cursorMode = Pointer; - } - } - - /** - * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`. - */ - function handleNoteDisplay() - { - if (noteDisplayDirty) - { - noteDisplayDirty = false; - - // Update for whether downscroll is enabled. - renderedNotes.flipX = (isViewDownscroll); - - // Calculate the view bounds. - var viewAreaTop:Float = this.scrollPositionInPixels - GRID_TOP_PAD; - var viewHeight:Float = (FlxG.height - MENU_BAR_HEIGHT); - var viewAreaBottom:Float = this.scrollPositionInPixels + viewHeight; - - // Remove notes that are no longer visible and list the ones that are. - var displayedNoteData:Array = []; - for (noteSprite in renderedNotes.members) - { - if (noteSprite == null || !noteSprite.exists || !noteSprite.visible) - continue; - - if (!noteSprite.isNoteVisible(viewAreaBottom, viewAreaTop)) - { - // This sprite is off-screen. - // Kill the note sprite and recycle it. - noteSprite.noteData = null; - } - else if (currentSongChartNoteData.indexOf(noteSprite.noteData) == -1) - { - // This note was deleted. - // Kill the note sprite and recycle it. - noteSprite.noteData = null; - } - else if (noteSprite.noteData.length > 0 && (noteSprite.parentNoteSprite == null && noteSprite.childNoteSprite == null)) - { - // Note was extended. - // Kill the note sprite and recycle it. - noteSprite.noteData = null; - } - else if (noteSprite.noteData.length == 0 && (noteSprite.parentNoteSprite != null || noteSprite.childNoteSprite != null)) - { - // Note was shortened. - // Kill the note sprite and recycle it. - noteSprite.noteData = null; - } - else - { - // Note is already displayed and should remain displayed. - displayedNoteData.push(noteSprite.noteData); - - // Update the note sprite's position. - noteSprite.updateNotePosition(renderedNotes); - } - } - - // Add notes that are now visible. - for (noteData in currentSongChartNoteData) - { - // Remember if we are already displaying this note. - if (displayedNoteData.indexOf(noteData) != -1) - { - continue; - } - - // Get the position the note should be at. - var noteTimePixels:Float = noteData.time / Conductor.stepCrochet * GRID_SIZE; - - // Make sure the note appears when scrolling up. - var modifiedViewAreaTop = viewAreaTop - GRID_SIZE; - - if (noteTimePixels < modifiedViewAreaTop || noteTimePixels > viewAreaBottom) - continue; - - // Else, this note is visible and we need to render it! - - // Get a note sprite from the pool. - // If we can reuse a deleted note, do so. - // If a new note is needed, call buildNoteSprite. - var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this)); - noteSprite.parentState = this; - - // The note sprite handles animation playback and positioning. - noteSprite.noteData = noteData; - - // Setting note data resets position relative to the grid so we fix that. - noteSprite.x += renderedNotes.x; - noteSprite.y += renderedNotes.y; - - if (noteSprite.noteData.length > 0) - { - // If the note is a hold, we need to make sure it's long enough. - var noteLengthMs:Float = noteSprite.noteData.length; - var noteLengthSteps:Float = (noteLengthMs / Conductor.stepCrochet); - var lastNoteSprite:ChartEditorNoteSprite = noteSprite; - - while (noteLengthSteps > 0) - { - if (noteLengthSteps <= 1.0) - { - // Last note in the hold. - // TODO: We may need to make it shorter and clip it visually. - } - - var nextNoteSprite:ChartEditorNoteSprite = renderedNotes.recycle(ChartEditorNoteSprite); - nextNoteSprite.parentState = this; - nextNoteSprite.parentNoteSprite = lastNoteSprite; - lastNoteSprite.childNoteSprite = nextNoteSprite; - - lastNoteSprite = nextNoteSprite; - - noteLengthSteps -= 1; - } - - // Make sure the last note sprite shows the end cap properly. - lastNoteSprite.childNoteSprite = null; - - // var noteLengthPixels:Float = (noteLengthMs / Conductor.stepCrochet + 1) * GRID_SIZE; - // add(new FlxSprite(noteSprite.x, noteSprite.y - renderedNotes.y + noteLengthPixels).makeGraphic(40, 2, 0xFFFF0000)); - } - } - - // Destroy and recreate smaller selection squares. - for (member in renderedNoteSelectionSquares.members) - { - // Killing the sprite is cheap because we can recycle it. - member.kill(); - } - - for (noteSprite in renderedNotes.members) - { - if (isNoteSelected(noteSprite.noteData) && noteSprite.parentNoteSprite == null) - { - var selectionSquare:FlxSprite = renderedNoteSelectionSquares.recycle(() -> - { - return new FlxSprite().loadGraphic(selectionSquareBitmap); - }); - - selectionSquare.x = noteSprite.x; - selectionSquare.y = noteSprite.y; - selectionSquare.width = noteSprite.width; - selectionSquare.height = noteSprite.height; - } - } - - // Sort the notes DESCENDING. This keeps the sustain behind the associated note. - renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); - } - } - - /** - * Handles display elements for the playbar at the bottom. - */ - function handlePlaybar() - { - // Make sure the playbar is never nudged out of the correct spot. - playbarHeadLayout.x = 4; - playbarHeadLayout.y = FlxG.height - 48 - 8; - - var songPos = Conductor.songPosition; - var songRemaining = songLengthInMs - songPos; - - // Move the playhead to match the song position, if we aren't dragging it. - if (!playbarHeadDragging) - { - var songPosPercent:Float = songPos / songLengthInMs; - playbarHead.value = songPosPercent * 100; - } - - var songPosSeconds:String = Std.string(Math.floor((songPos / 1000) % 60)).lpad('0', 2); - var songPosMinutes:String = Std.string(Math.floor((songPos / 1000) / 60)).lpad('0', 2); - var songPosString:String = '${songPosMinutes}:${songPosSeconds}'; - - setUIValue('playbarSongPos', songPosString); - - var songRemainingSeconds:String = Std.string(Math.floor((songRemaining / 1000) % 60)).lpad('0', 2); - var songRemainingMinutes:String = Std.string(Math.floor((songRemaining / 1000) / 60)).lpad('0', 2); - var songRemainingString:String = '-${songRemainingMinutes}:${songRemainingSeconds}'; - - setUIValue('playbarSongRemaining', songRemainingString); - } - - /** - * Handle keybinds for File menu items. - */ - function handleFileKeybinds() - { - // CTRL + Q = Quit to Menu - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q) - { - FlxG.switchState(new MainMenuState()); - } - } - - /** - * Handle keybinds for edit menu items. - */ - function handleEditKeybinds() - { - // CTRL + Z = Undo - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Z) - { - undoLastCommand(); - } - - if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.Z && !FlxG.keys.pressed.Y) - { - undoHeldTime += FlxG.elapsed; - } - else - { - undoHeldTime = 0; - } - if (undoHeldTime > RAPID_UNDO_DELAY + RAPID_UNDO_INTERVAL) - { - undoLastCommand(); - undoHeldTime -= RAPID_UNDO_INTERVAL; - } - - // CTRL + Y = Redo - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Y) - { - redoLastCommand(); - } - - if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.Y && !FlxG.keys.pressed.Z) - { - redoHeldTime += FlxG.elapsed; - } - else - { - redoHeldTime = 0; - } - if (redoHeldTime > RAPID_UNDO_DELAY + RAPID_UNDO_INTERVAL) - { - redoLastCommand(); - redoHeldTime -= RAPID_UNDO_INTERVAL; - } - - // CTRL + C = Copy - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.C) - { - // Copy selected notes. - // We don't need a command for this since we can't undo it. - SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(currentSelection)); - } - - // CTRL + X = Cut - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.X) - { - // Cut selected notes. - performCommand(new CutNotesCommand(currentSelection)); - } - - // CTRL + V = Paste - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.V) - { - // Paste notes from clipboard, at the playhead. - performCommand(new PasteNotesCommand(scrollPositionInMs + playheadPositionInMs)); - } - - // DELETE = Delete - if (FlxG.keys.justPressed.DELETE) - { - // Delete selected notes. - performCommand(new RemoveNotesCommand(currentSelection)); - } - - // CTRL + A = Select All - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A) - { - // Select all notes. - performCommand(new SelectAllNotesCommand(currentSelection)); - } - - // CTRL + I = Select Inverse - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I) - { - // Select unselected notes and deselect selected notes.. - performCommand(new InvertSelectedNotesCommand(currentSelection)); - } - - // CTRL + D = Select None - if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D) - { - // Deselect all notes. - performCommand(new DeselectAllNotesCommand(currentSelection)); - } - } - - /** - * Handle keybinds for View menu items. - */ - function handleViewKeybinds() - { - } - - /** - * Handle keybinds for Help menu items. - */ - function handleHelpKeybinds() - { - // F1 = Open Help - if (FlxG.keys.justPressed.F1) - ChartEditorDialogHandler.openUserGuideDialog(this); - } - - function handleToolboxes() - { - handleDifficultyToolbox(); - handlePlayerPreviewToolbox(); - handleOpponentPreviewToolbox(); - } - - function handleDifficultyToolbox() - { - if (difficultySelectDirty) - { - difficultySelectDirty = false; - - // Manage the Select Difficulty tree view. - var difficultyToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); - if (difficultyToolbox == null) - return; - - var treeView:TreeView = difficultyToolbox.findComponent('difficultyToolboxTree'); - if (treeView == null) - return; - - // Clear the tree view so we can rebuild it. - treeView.clearNodes(); - - var treeSong = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName', icon: "haxeui-core/styles/default/haxeui_tiny.png"}); - treeSong.expanded = true; - - for (curVariation in availableVariations) - { - var variationMetadata:SongMetadata = songMetadata.get(curVariation); - - var treeVariation = treeSong.addNode({ - id: 'stv_variation_$curVariation', - text: 'V: ${curVariation.toTitleCase()}', - // icon: "haxeui-core/styles/default/haxeui_tiny.png" - }); - treeVariation.expanded = true; - - var difficultyList = variationMetadata.playData.difficulties; - - for (difficulty in difficultyList) - { - var treeDifficulty = treeVariation.addNode({ - id: 'stv_difficulty_${curVariation}_$difficulty', - text: 'D: ${difficulty.toTitleCase()}', - // icon: "haxeui-core/styles/default/haxeui_tiny.png" - }); - } - } - - treeView.onChange = onChangeTreeDifficulty; - treeView.selectedNode = getCurrentTreeDifficultyNode(); - } - } - - function handlePlayerPreviewToolbox() - { - // Manage the Select Difficulty tree view. - var charPreviewToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT); - if (charPreviewToolbox == null) - return; - - var charPlayer:CharacterPlayer = charPreviewToolbox.findComponent('charPlayer'); - if (charPlayer == null) - return; - - currentPlayerCharacterPlayer = charPlayer; - } - - function handleOpponentPreviewToolbox() - { - // Manage the Select Difficulty tree view. - var charPreviewToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT); - if (charPreviewToolbox == null) - return; - - var charPlayer:CharacterPlayer = charPreviewToolbox.findComponent('charPlayer'); - if (charPlayer == null) - return; - - currentOpponentCharacterPlayer = charPlayer; - } - - override function dispatchEvent(event:ScriptEvent) - { - super.dispatchEvent(event); - - // We can't use the ScriptedEventDispatcher with currentCharPlayer because we can't use the IScriptedClass interface on it. - if (currentPlayerCharacterPlayer != null) - { - switch (event.type) - { - case ScriptEvent.UPDATE: - currentPlayerCharacterPlayer.onUpdate(cast event); - case ScriptEvent.SONG_BEAT_HIT: - currentPlayerCharacterPlayer.onBeatHit(cast event); - case ScriptEvent.SONG_STEP_HIT: - currentPlayerCharacterPlayer.onStepHit(cast event); - case ScriptEvent.NOTE_HIT: - currentPlayerCharacterPlayer.onNoteHit(cast event); - } - } - - if (currentOpponentCharacterPlayer != null) - { - switch (event.type) - { - case ScriptEvent.UPDATE: - currentOpponentCharacterPlayer.onUpdate(cast event); - case ScriptEvent.SONG_BEAT_HIT: - currentOpponentCharacterPlayer.onBeatHit(cast event); - case ScriptEvent.SONG_STEP_HIT: - currentOpponentCharacterPlayer.onStepHit(cast event); - case ScriptEvent.NOTE_HIT: - currentOpponentCharacterPlayer.onNoteHit(cast event); - } - } - } - - function getCurrentTreeDifficultyNode():TreeViewNode - { - var treeView:TreeView = findComponent('difficultyToolboxTree'); - - if (treeView == null) - return null; - - var result = treeView.findNodeByPath('stv_song/stv_variation_$selectedVariation/stv_difficulty_${selectedVariation}_$selectedDifficulty', 'id'); - - if (result == null) - return null; - - return result; - } - - function onChangeTreeDifficulty(event:UIEvent):Void - { - // Get the newly selected node. - var treeView:TreeView = cast event.target; - var targetNode:TreeViewNode = treeView.selectedNode; - - if (targetNode == null) - { - trace('No target node!'); - // Reset the user's selection. - treeView.selectedNode = getCurrentTreeDifficultyNode(); - return; - } - - switch (targetNode.data.id.split('_')[1]) - { - case 'difficulty': - var variation = targetNode.data.id.split('_')[2]; - var difficulty = targetNode.data.id.split('_')[3]; - - if (variation != null && difficulty != null) - { - trace('Changing difficulty to $variation:$difficulty'); - selectedVariation = variation; - selectedDifficulty = difficulty; - } - // case 'song': - // case 'variation': - default: - // Reset the user's selection. - trace('Selected wrong node type, resetting selection.'); - treeView.selectedNode = getCurrentTreeDifficultyNode(); - } - } - - function addDifficulty(variation:String) - { - } - - function addVariation(variationId:String) - { - // Create a new variation with the specified ID. - songMetadata.set(variationId, currentSongMetadata.clone(variationId)); - // Switch to the new variation. - selectedVariation = variationId; - } - - /** - * Handle the player preview/gameplay test area on the left side. - */ - function handlePlayerDisplay() - { - } - - /** - * Handles the note preview/scroll area on the right side. - * Notes are rendered here as small bars. - * This function also handles: - * - Moving the viewport preview box around based on its current position. - * - Scrolling the note preview area down if the note preview is taller than the screen, - * and the viewport nears the end of the visible area. - */ - function handleNotePreview() - { - // - if (notePreviewDirty) - { - notePreviewDirty = false; - - var PREVIEW_WIDTH:Int = GRID_SIZE * 2; - var STEP_HEIGHT:Int = 1; - var PREVIEW_HEIGHT:Int = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * STEP_HEIGHT); - - notePreviewBitmap = new BitmapData(PREVIEW_WIDTH, PREVIEW_HEIGHT, true); - notePreviewBitmap.fillRect(new Rectangle(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT), PREVIEW_BG_COLOR); - } - } - - /** - * Perform a spot update on the note preview, by editing the note preview - * only where necessary. More efficient than a full update. - */ - function updateNotePreview(note:SongNoteData, ?deleteNote:Bool = false) - { - } - - /** - * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status. - * Does not handle onClick ACTIONS of the menubar. - */ - function handleMenubar() - { - if (commandHistoryDirty) - { - commandHistoryDirty = false; - - // Update the Undo and Redo buttons. - var undoButton:MenuItem = findComponent('menubarItemUndo', MenuItem); - - if (undoButton != null) - { - if (undoHistory.length == 0) - { - // Disable the Undo button. - undoButton.disabled = true; - undoButton.text = "Undo"; - } - else - { - // Change the label to the last command. - undoButton.disabled = false; - undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}'; - } - } - else - { - trace("undoButton is null"); - } - - var redoButton:MenuItem = findComponent('menubarItemRedo', MenuItem); - - if (redoButton != null) - { - if (redoHistory.length == 0) - { - // Disable the Redo button. - redoButton.disabled = true; - redoButton.text = "Redo"; - } - else - { - // Change the label to the last command. - redoButton.disabled = false; - redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}'; - } - } - else - { - trace("redoButton is null"); - } - } - } - - /** - * Handle syncronizing the conductor with the music playback. - */ - function handleMusicPlayback() - { - if (audioInstTrack != null && audioInstTrack.playing) - { - if (FlxG.mouse.pressedMiddle) - { - // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat! - - var oldStepTime = Conductor.currentStepTime; - var oldSongPosition = Conductor.songPosition; - Conductor.update(audioInstTrack.time); - handleHitsounds(oldSongPosition, Conductor.songPosition); - // Resync vocals. - if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) - audioVocalTrackGroup.time = audioInstTrack.time; - var diffStepTime = Conductor.currentStepTime - oldStepTime; - - // Move the playhead. - playheadPositionInPixels += diffStepTime * GRID_SIZE; - - // We don't move the song to scroll position, or update the note sprites. - } - else - { - // Else, move the entire view. - var oldSongPosition = Conductor.songPosition; - Conductor.update(audioInstTrack.time); - handleHitsounds(oldSongPosition, Conductor.songPosition); - // Resync vocals. - if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) - audioVocalTrackGroup.time = audioInstTrack.time; - - // We need time in fractional steps here to allow the song to actually play. - // Also account for a potentially offset playhead. - scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels; - - // DO NOT move song to scroll position here specifically. - - // We need to update the note sprites. - noteDisplayDirty = true; - } - } - - if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen) - { - toggleAudioPlayback(); - } - } - - /** - * Handle the playback of hitsounds. - */ - function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void - { - if (!hitsoundsEnabled) - return; - - // Assume notes are sorted by time. - for (noteData in currentSongChartNoteData) - { - if (noteData.time < oldSongPosition) - // Note is in the past. - continue; - - if (noteData.time >= newSongPosition) - // Note is in the future. - return; - - // Note was just hit. - - // 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; - tempNote.scrollFactor.set(0, 0); - var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true); - dispatchEvent(event); - - // Calling event.cancelEvent() skips all the other logic! Neat! - if (event.eventCanceled) - continue; - - // Hitsounds. - switch (noteData.getStrumlineIndex()) - { - case 0: // Player - if (hitsoundsEnabledPlayer) - playSound(Paths.sound('funnyNoise/funnyNoise-09')); - case 1: // Opponent - if (hitsoundsEnabledOpponent) - playSound(Paths.sound('funnyNoise/funnyNoise-010')); - } - } - } - - function startAudioPlayback() - { - if (audioInstTrack != null) - audioInstTrack.play(); - if (audioVocalTrackGroup != null) - audioVocalTrackGroup.play(); - if (audioVocalTrackGroup != null) - audioVocalTrackGroup.play(); - } - - function stopAudioPlayback() - { - if (audioInstTrack != null) - audioInstTrack.pause(); - if (audioVocalTrackGroup != null) - audioVocalTrackGroup.pause(); - if (audioVocalTrackGroup != null) - audioVocalTrackGroup.pause(); - } - - function toggleAudioPlayback() - { - if (audioInstTrack == null) - return; - - if (audioInstTrack.playing) - { - stopAudioPlayback(); - } - else - { - startAudioPlayback(); - } - } - - function handlePlayhead() - { - // Place notes at the playhead. - // TODO: Add the ability to switch modes. - if (true) - { - if (FlxG.keys.justPressed.ONE) - placeNoteAtPlayhead(0); - if (FlxG.keys.justPressed.TWO) - placeNoteAtPlayhead(1); - if (FlxG.keys.justPressed.THREE) - placeNoteAtPlayhead(2); - if (FlxG.keys.justPressed.FOUR) - placeNoteAtPlayhead(3); - if (FlxG.keys.justPressed.FIVE) - placeNoteAtPlayhead(4); - if (FlxG.keys.justPressed.SIX) - placeNoteAtPlayhead(5); - if (FlxG.keys.justPressed.SEVEN) - placeNoteAtPlayhead(6); - if (FlxG.keys.justPressed.EIGHT) - placeNoteAtPlayhead(7); - } - } - - function placeNoteAtPlayhead(column:Int):Void - { - var gridSnappedPlayheadPos = scrollPositionInPixels - (scrollPositionInPixels % GRID_SIZE); - } - - function set_scrollPositionInPixels(value:Float):Float - { - if (value < 0) - { - // If we're scrolling up, and we hit the top, - // but the playhead is in the middle, move the playhead up. - if (playheadPositionInPixels > 0) - { - var amount = scrollPositionInPixels - value; - playheadPositionInPixels -= amount; - } - - value = 0; - } - - if (value > songLengthInPixels) - value = songLengthInPixels; - - if (value == scrollPositionInPixels) - return value; - - this.scrollPositionInPixels = value; - - // Move the grid sprite to the correct position. - if (isViewDownscroll) - { - gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); - } - else - { - gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); - } - // Move the rendered notes to the correct position. - renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); - renderedNoteSelectionSquares.setPosition(renderedNotes.x, renderedNotes.y); - if (gridSpectrogram != null) - { - // Move the spectrogram to the correct position. - gridSpectrogram.y = gridTiledSprite.y; - gridSpectrogram.setPosition(0, 0); - } - - return this.scrollPositionInPixels; - } - - function get_playheadPositionInPixels():Float - { - return this.playheadPositionInPixels; - } - - function set_playheadPositionInPixels(value:Float):Float - { - // Make sure playhead doesn't go outside the song. - if (value + scrollPositionInPixels < 0) - value = -scrollPositionInPixels; - if (value + scrollPositionInPixels > songLengthInPixels) - value = songLengthInPixels - scrollPositionInPixels; - - this.playheadPositionInPixels = value; - - // Move the playhead sprite to the correct position. - gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); - - return this.playheadPositionInPixels; - } - - /** - * Loads an instrumental from an absolute file path, replacing the current instrumental. - */ - public function loadInstrumentalFromPath(path:String):Void - { - #if sys - var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path); - loadInstrumentalFromBytes(fileBytes); - #else - trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); - #end - } - - /** - * Loads an instrumental from audio byte data, replacing the current instrumental. - */ - public function loadInstrumentalFromBytes(bytes:haxe.io.Bytes):Void - { - var openflSound = new openfl.media.Sound(); - openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); - audioInstTrack = FlxG.sound.load(openflSound, 1.0, false); - audioInstTrack.autoDestroy = false; - audioInstTrack.pause(); - - // Tell the user the load was successful. - // TODO: Un-bork this. - // showNotification('Loaded instrumental track successfully.'); - - postLoadInstrumental(); - } - - public function loadInstrumentalFromAsset(path:String):Void - { - var instTrack = FlxG.sound.load(path, 1.0, false); - audioInstTrack = instTrack; - - postLoadInstrumental(); - } - - function postLoadInstrumental() - { - // Prevent the time from skipping back to 0 when the song ends. - audioInstTrack.onComplete = function() - { - if (audioInstTrack != null) - audioInstTrack.pause(); - if (audioVocalTrackGroup != null) - audioVocalTrackGroup.pause(); - }; - - songLengthInMs = audioInstTrack.length; - - gridTiledSprite.height = songLengthInPixels; - if (gridSpectrogram != null) - { - gridSpectrogram.setSound(audioInstTrack); - gridSpectrogram.generateSection(0, songLengthInMs / 1000); - } - - scrollPositionInPixels = 0; - playheadPositionInPixels = 0; - moveSongToScrollPosition(); - } - - /** - * Loads a vocal track from an absolute file path. - */ - public function loadVocalsFromPath(path:String):Void - { - #if sys - var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path); - loadVocalsFromBytes(fileBytes); - #else - trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); - #end - } - - public function loadVocalsFromAsset(path:String):Void - { - var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false); - audioVocalTrackGroup.add(vocalTrack); - } - - /** - * Loads a vocal track from audio byte data. - */ - public function loadVocalsFromBytes(bytes:haxe.io.Bytes):Void - { - var openflSound = new openfl.media.Sound(); - openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); - var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false); - audioVocalTrackGroup.add(vocalTrack); - - // Tell the user the load was successful. - // TODO: Un-bork this. - // showNotification('Loaded instrumental track successfully.'); - } - - /** - * Fetch's a song's existing chart and audio and loads it, replacing the current song. - */ - public function loadSongAsTemplate(songId:String) - { - var song:Song = SongDataParser.fetchSong(songId); - - if (song == null) - { - // showNotification('Failed to load song template.'); - return; - } - - // Load the song metadata. - var rawSongMetadata:Array = song.getRawMetadata(); - - this.songMetadata = new Map(); - - for (metadata in rawSongMetadata) - { - var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation; - this.songMetadata.set(variation, metadata); - } - - this.songChartData = new Map(); - - for (metadata in rawSongMetadata) - { - var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation; - this.songChartData.set(variation, SongDataParser.parseSongChartData(songId, metadata.variation)); - } - - Conductor.forceBPM(null); // Disable the forced BPM. - Conductor.mapTimeChanges(currentSongMetadata.timeChanges); - - loadInstrumentalFromAsset(Paths.inst(songId)); - loadVocalsFromAsset(Paths.voices(songId)); - - // showNotification('Loaded song ${songId}.'); - } - - /** - * When setting the scroll position, except when automatically scrolling during song playback, - * we need to update the conductor's current step time and the timestamp of the audio tracks. - */ - function moveSongToScrollPosition() - { - // Update the songPosition in the Conductor. - Conductor.update(scrollPositionInMs); - - // Update the songPosition in the audio tracks. - if (audioInstTrack != null) - audioInstTrack.time = scrollPositionInMs + playheadPositionInMs; - if (audioVocalTrackGroup != null) - audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs; - - // We need to update the note sprites because we changed the scroll position. - noteDisplayDirty = true; - } - - /** - * Perform (or redo) a command, then add it to the undo stack. - * - * @param command The command to perform. - * @param purgeRedoStack If true, the redo stack will be cleared. - */ - function performCommand(command:ChartEditorCommand, ?purgeRedoStack:Bool = true):Void - { - command.execute(this); - undoHistory.push(command); - commandHistoryDirty = true; - if (purgeRedoStack) - redoHistory = []; - } - - /** - * Undo a command, then add it to the redo stack. - * @param command The command to undo. - */ - function undoCommand(command:ChartEditorCommand):Void - { - command.undo(this); - redoHistory.push(command); - commandHistoryDirty = true; - } - - /** - * Undo the last command in the undo stack, then add it to the redo stack. - */ - function undoLastCommand():Void - { - if (undoHistory.length == 0) - { - trace('No actions to undo.'); - return; - } - - var command = undoHistory.pop(); - undoCommand(command); - } - - /** - * Redo the last command in the redo stack, then add it to the undo stack. - */ - function redoLastCommand():Void - { - if (redoHistory.length == 0) - { - trace('No actions to redo.'); - return; - } - - var command = redoHistory.pop(); - performCommand(command, false); - } - - function sortChartData() - { - currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int - { - return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); - }); - - currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int - { - return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); - }); - } - - function playMetronomeTick(?high:Bool = false) - { - playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}')); - } - - function isNoteSelected(note:SongNoteData):Bool - { - return currentSelection.indexOf(note) != -1; - } - - /** - * Play a sound effect. - * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance. - */ - function playSound(path:String) - { - var snd:FlxSound = FlxG.sound.list.recycle(FlxSound); - snd.loadEmbedded(FlxG.sound.cache(path)); - snd.autoDestroy = true; - FlxG.sound.list.add(snd); - snd.play(); - } - - override function destroy() - { - super.destroy(); - - cleanupAutoSave(); - - @:privateAccess - ChartEditorNoteSprite.noteFrameCollection = null; - } - - /** - * Displays a notification to the user. The only action is to dismiss. - */ - function showNotification(text:String) - { - var notifBarText:Label = notifBar.findComponent('notifBarText', Label); - var notifBarAction1:Button = notifBar.findComponent('notifBarAction1', Button); - - // Make it appear. - notifBar.show(); - - // Don't shift the UI up. - notifBar.method = "float"; - // Anchor to far right. - notifBar.x = FlxG.width - notifBar.width; - - // Set the message. - notifBarText.text = text; - - // Configure the action button. - notifBarAction1.text = 'Dismiss'; - notifBarAction1.onClick = (_:UIEvent) -> dismissNotification(); - - // Auto dismiss. - new FlxTimer().start(NOTIFICATION_DISMISS_TIME, (_:FlxTimer) -> dismissNotification()); - } - - /** - * Dismiss any existing notifications, if there are any. - */ - function dismissNotification():Void - { - notifBar.hide(); - } - - /** - * @param force Whether to force the export without prompting the user for a file location. - * @param tmp If true, save to the temporary directory instead of the local `backup` directory. - */ - public function exportAllSongData(?force:Bool = false, ?tmp:Bool = false):Void - { - var zipEntries = []; - - for (variation in availableVariations) - { - var variationId = variation; - if (variation == '' || variation == 'default' || variation == 'normal') - { - variationId = ''; - } - - if (variationId == '') - { - var variationMetadata = songMetadata.get(variation); - zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata.json', SerializerUtil.toJSON(variationMetadata))); - var variationChart = songChartData.get(variation); - zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart.json', SerializerUtil.toJSON(variationChart))); - } - else - { - var variationMetadata = songMetadata.get(variation); - zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata-$variationId.json', SerializerUtil.toJSON(variationMetadata))); - var variationChart = songChartData.get(variation); - zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart-$variationId.json', SerializerUtil.toJSON(variationChart))); - } - } - - // TODO: Add audio files to the ZIP. - - trace('Exporting ${zipEntries.length} files to ZIP...'); - - if (force) - { - var targetPath:String = tmp ? Path.join([FileUtil.getTempDir(), 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']) : Path.join(['./backups/', 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']); - - // We have to force write because the program will die before the save dialog is closed. - trace('Force exporting to $targetPath...'); - FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath); - return; - } - - // Prompt and save. - var onSave:Array->Void = (paths:Array) -> - { - trace('Successfully exported files.'); - }; - - var onCancel:Void->Void = () -> - { - trace('Export cancelled.'); - }; - - FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '$currentSongId-chart.zip'); - } + /** + * CONSTANTS + */ + // ============================== + // XML Layouts + static final CHART_EDITOR_LAYOUT = Paths.ui('chart-editor/main-view'); + + static final CHART_EDITOR_NOTIFBAR_LAYOUT = Paths.ui('chart-editor/components/notifbar'); + static final CHART_EDITOR_PLAYBARHEAD_LAYOUT = Paths.ui('chart-editor/components/playbar-head'); + + static final CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT = Paths.ui('chart-editor/toolbox/tools'); + static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT = Paths.ui('chart-editor/toolbox/notedata'); + static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT = Paths.ui('chart-editor/toolbox/eventdata'); + static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT = Paths.ui('chart-editor/toolbox/metadata'); + static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT = Paths.ui('chart-editor/toolbox/difficulty'); + static final CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT = Paths.ui('chart-editor/toolbox/characters'); + static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/player-preview'); + static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/opponent-preview'); + + /** + * The base grid size for the chart editor. + */ + public static final GRID_SIZE:Int = 40; + + public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12; + + public static final PLAYHEAD_HEIGHT:Int = Std.int(GRID_SIZE / 8); + + public static final GRID_SELECTION_BORDER_WIDTH:Int = 6; + + /** + * Number of notes in each player's strumline. + */ + public static final STRUMLINE_SIZE = 4; + + /** + * The height of the menu bar in the layout. + */ + static final MENU_BAR_HEIGHT = 32; + + /** + * Duration to wait before autosaving the chart. + */ + static final AUTOSAVE_TIMER_DELAY:Float = 60.0 * 5.0; + + /** + * The amount of padding between the menu bar and the chart grid when fully scrolled up. + */ + static final GRID_TOP_PAD:Int = 8; + + /** + * Duration, in seconds, until toast notifications are automatically hidden. + */ + static final NOTIFICATION_DISMISS_TIME:Float = 3.0; + + // Start performing rapid undo after this many seconds. + static final RAPID_UNDO_DELAY:Float = 0.4; + // Perform a rapid undo every this many seconds. + static final RAPID_UNDO_INTERVAL:Float = 0.1; + + // UI Element Colors + // Background color tint. + static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF; + static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030; + static final PLAYHEAD_SCROLL_AREA_COLOR:FlxColor = 0xFF682B2F; + static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000; + static final PLAYHEAD_COLOR:FlxColor = 0xC0BD0231; + + /** + * How many pixels far the user needs to move the mouse before the cursor is considered to be dragged rather than clicked. + */ + static final DRAG_THRESHOLD:Float = 16.0; + + /** + * Types of notes you can snap to. + */ + static final SNAP_QUANTS:Array = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192]; + + /** + * INSTANCE DATA + */ + // ============================== + public var currentZoomLevel:Float = 1.0; + + var noteSnapQuantIndex:Int = 3; + + public var noteSnapQuant(get, never):Int; + + function get_noteSnapQuant():Int + { + return SNAP_QUANTS[noteSnapQuantIndex]; + } + + /** + * scrollPosition is the current position in the song, in pixels. + * One pixel is 1/40 of 1 step, and 1/160 of 1 beat. + */ + var scrollPositionInPixels(default, set):Float = -1.0; + + /** + * scrollPosition, converted to steps. + * TODO: Handle BPM changes. + */ + var scrollPositionInSteps(get, null):Float; + + function get_scrollPositionInSteps():Float + { + return scrollPositionInPixels / GRID_SIZE; + } + + /** + * scrollPosition, converted to milliseconds. + * TODO: Handle BPM changes. + */ + var scrollPositionInMs(get, set):Float; + + function get_scrollPositionInMs():Float + { + return scrollPositionInSteps * Conductor.stepCrochet; + } + + function set_scrollPositionInMs(value:Float):Float + { + scrollPositionInPixels = value / Conductor.stepCrochet; + return value; + } + + /** + * The position of the playhead, in pixels, relative to the scrollPosition. + * 0 means playhead is at the top of the grid. + * 40 means the playhead is 1 grid length below the base position. + * -40 means the playhead is 1 grid length above the base position. + */ + var playheadPositionInPixels(default, set):Float; + + var playheadPositionInSteps(get, null):Float; + + /** + * playheadPosition, converted to steps. + */ + function get_playheadPositionInSteps():Float + { + return playheadPositionInPixels / GRID_SIZE; + } + + /** + * playheadPosition, converted to milliseconds. + */ + var playheadPositionInMs(get, null):Float; + + function get_playheadPositionInMs():Float + { + return playheadPositionInSteps * Conductor.stepCrochet; + } + + /** + * This is the song's length in PIXELS, same format as scrollPosition. + */ + var songLengthInPixels(get, default):Int; + + function get_songLengthInPixels():Int + { + if (songLengthInPixels <= 0) + return 1000; + + return songLengthInPixels; + } + + /** + * songLength, converted to steps. + * TODO: Handle BPM changes. + */ + var songLengthInSteps(get, set):Float; + + function get_songLengthInSteps():Float + { + return songLengthInPixels / GRID_SIZE; + } + + function set_songLengthInSteps(value:Float):Float + { + songLengthInPixels = Std.int(value * GRID_SIZE); + return value; + } + + /** + * songLength, converted to milliseconds. + * TODO: Handle BPM changes. + */ + var songLengthInMs(get, set):Float; + + function get_songLengthInMs():Float + { + return songLengthInSteps * Conductor.stepCrochet; + } + + function set_songLengthInMs(value:Float):Float + { + songLengthInSteps = Conductor.getTimeInSteps(audioInstTrack.length); + return value; + } + + var currentTheme(default, set):ChartEditorTheme = null; + + function set_currentTheme(value:ChartEditorTheme):ChartEditorTheme + { + if (value == null || value == currentTheme) + return currentTheme; + + currentTheme = value; + ChartEditorThemeHandler.updateTheme(this); + return value; + } + + /** + * Whether a skip button has been pressed on the playbar, and which one. + * This will be used to update the scrollPosition (in the same function that handles the scroll wheel), then cleared. + */ + var playbarButtonPressed:String = null; + + /** + * Whether the head of the playbar is currently being dragged with the mouse by the user. + */ + var playbarHeadDragging:Bool = false; + + /** + * Whether music was playing before we started dragging the playbar head. + * If so, then when we stop dragging the playbar head, we should resume song playback. + */ + var playbarHeadDraggingWasPlaying:Bool = false; + + /** + * The note kind to use for notes being placed in the chart. Defaults to `''`. + */ + var selectedNoteKind:String = ''; + + /** + * The note kind to use for notes being placed in the chart. Defaults to `''`. + */ + var selectedEventKind:String = 'FocusCamera'; + + /** + * The note data as a struct. + */ + var selectedEventData:DynamicAccess = {}; + + /** + * Whether to play a metronome sound while the playhead is moving. + */ + var shouldPlayMetronome:Bool = true; + + /** + * Use the tool window to affect how the user interacts with the program. + */ + var currentToolMode:ChartEditorToolMode = ChartEditorToolMode.Select; + + /** + * The character sprite in the Player Preview window. + */ + var currentPlayerCharacterPlayer:CharacterPlayer = null; + + /** + * The character sprite in the Opponent Preview window. + */ + var currentOpponentCharacterPlayer:CharacterPlayer = null; + + /** + * Whether the current view is in downscroll mode. + */ + var isViewDownscroll(default, set):Bool = false; + + function set_isViewDownscroll(value:Bool):Bool + { + isViewDownscroll = value; + + // Make sure view is updated when we change view modes. + noteDisplayDirty = true; + notePreviewDirty = true; + this.scrollPositionInPixels = this.scrollPositionInPixels; + + return isViewDownscroll; + } + + /** + * Whether hitsounds are enabled for at least one character. + */ + var hitsoundsEnabled(get, null):Bool; + + function get_hitsoundsEnabled():Bool + { + return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent; + } + + /** + * Whether hitsounds are enabled for the player. + */ + var hitsoundsEnabledPlayer:Bool = true; + + /** + * Whether hitsounds are enabled for the opponent. + */ + var hitsoundsEnabledOpponent:Bool = true; + + /** + * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI. + * If so, ignore mouse events underneath. + */ + var isCursorOverHaxeUI(get, null):Bool; + + function get_isCursorOverHaxeUI():Bool + { + return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY); + } + + var isCursorOverHaxeUIButton(get, null):Bool; + + function get_isCursorOverHaxeUIButton():Bool + { + return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Button) + || Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Link); + } + + /** + * Set by ChartEditorDialogHandler, used to prevent background interaction while the dialog is open. + */ + public var isHaxeUIDialogOpen:Bool = false; + + /** + * The variation ID for the difficulty which is currently being edited. + */ + var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION; + + function set_selectedVariation(value:String):String + { + selectedVariation = value; + + // Make sure view is updated when the variation changes. + noteDisplayDirty = true; + notePreviewDirty = true; + + return selectedVariation; + } + + /** + * The difficulty ID for the difficulty which is currently being edited. + */ + var selectedDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY; + + function set_selectedDifficulty(value:String):String + { + selectedDifficulty = value; + + // Make sure view is updated when the difficulty changes. + noteDisplayDirty = true; + notePreviewDirty = true; + + return selectedDifficulty; + } + + /** + * Whether the user is currently in Pattern Mode. + * This overrides the chart editor's normal behavior. + */ + var isInPatternMode(default, set):Bool = false; + + function set_isInPatternMode(value:Bool):Bool + { + isInPatternMode = value; + + // Make sure view is updated when we change modes. + noteDisplayDirty = true; + notePreviewDirty = true; + this.scrollPositionInPixels = 0; + + return isInPatternMode; + } + + var currentPattern:String = ''; + + /** + * Whether the note display render group has been modified and needs to be updated. + * This happens when we scroll or add/remove notes, and need to update what notes are displayed and where. + */ + var noteDisplayDirty:Bool = true; + + /** + * Whether the note preview graphic needs to be FULLY rebuilt. + * The Bitmap can be modified by individual commands without using this. + */ + var notePreviewDirty:Bool = true; + + /** + * Whether the chart has been modified since it was last saved. + * Used to determine whether to auto-save, etc. + */ + var saveDataDirty(default, set):Bool = false; + + function set_saveDataDirty(value:Bool):Bool + { + if (value == saveDataDirty) + return value; + + if (value) + { + // Start the auto-save timer. + autoSaveTimer = new FlxTimer().start(AUTOSAVE_TIMER_DELAY, (_) -> autoSave()); + } + else + { + // Stop the auto-save timer. + autoSaveTimer.cancel(); + autoSaveTimer.destroy(); + autoSaveTimer = null; + } + + return saveDataDirty = value; + } + + /** + * A timer used to auto-save the chart after a period of inactivity. + */ + var autoSaveTimer:FlxTimer; + + /** + * Whether the difficulty tree view in the toolbox has been modified and needs to be updated. + * This happens when we add/remove difficulties. + */ + var difficultySelectDirty:Bool = true; + + /** + * Whether the character select view in the toolbox has been modified and needs to be updated. + * This happens when we add/remove characters. + */ + var characterSelectDirty:Bool = true; + + var isInPlaytestMode:Bool = false; + + /** + * The list of command previously performed. Used for undoing previous actions. + */ + var undoHistory:Array = []; + + /** + * The list of commands that have been undone. Used for redoing previous actions. + */ + var redoHistory:Array = []; + + /** + * Variable used to track how long the user has been holding the undo keybind. + */ + var undoHeldTime:Float = 0.0; + + /** + * Variable used to track how long the user has been holding the redo keybind. + */ + var redoHeldTime:Float = 0.0; + + /** + * Whether the undo/redo histories have changed since the last time the UI was updated. + */ + var commandHistoryDirty:Bool = true; + + /** + * The notes which are currently in the user's selection. + */ + var currentNoteSelection:Array = []; + + /** + * The events which are currently in the user's selection. + */ + var currentEventSelection:Array = []; + + /** + * The position where the user clicked to start a selection. + * The selection box extends from this point to the current mouse position. + */ + var selectionBoxStartPos:FlxPoint = null; + + /** + * Whether the user's last mouse click was on the playhead scroll area. + */ + var gridPlayheadScrollAreaPressed:Bool = false; + + /** + * The SongNoteData which is currently being placed. + * As the user drags, we will update this note's sustain length. + */ + var currentPlaceNoteData:SongNoteData = null; + + /** + * The Dialog components representing the currently available tool windows. + * Dialogs are retained here even when collapsed or hidden. + */ + var activeToolboxes:Map = new Map(); + + /** + * AUDIO AND SOUND DATA + */ + // ============================== + + /** + * The audio track for the instrumental. + */ + var audioInstTrack:FlxSound; + + /** + * The audio track for the vocals. + */ + var audioVocalTrackGroup:VocalGroup; + + /** + * A map of the audio tracks for each character's vocals. + * - Keys are the character IDs. + * - Values are the FlxSound objects to play that character's vocals. + * + * When switching characters, the elements of the VocalGroup will be swapped to match the new character. + */ + var audioVocalTracks:Map = new Map(); + + /** + * CHART DATA + */ + // ============================== + + /** + * The song metadata. + * - Keys are the variation IDs. At least one (`default`) must exist. + * - Values are the relevant metadata, ready to be serialized to JSON. + */ + var songMetadata:Map; + + var availableVariations(get, null):Array; + + function get_availableVariations():Array + { + return [for (x in songMetadata.keys()) x]; + } + + /** + * The song chart data. + * - Keys are the variation IDs. At least one (`default`) must exist. + * - Values are the relevant chart data, ready to be serialized to JSON. + */ + var songChartData:Map; + + /** + * Convenience property to get the chart data for the current variation. + */ + var currentSongMetadata(get, set):SongMetadata; + + function get_currentSongMetadata():SongMetadata + { + var result = songMetadata.get(selectedVariation); + if (result == null) + { + result = new SongMetadata('Dad Battle', 'Kawai Sprite', selectedVariation); + songMetadata.set(selectedVariation, result); + } + return result; + } + + function set_currentSongMetadata(value:SongMetadata):SongMetadata + { + songMetadata.set(selectedVariation, value); + return value; + } + + /** + * Convenience property to get the chart data for the current variation. + */ + var currentSongChartData(get, set):SongChartData; + + function get_currentSongChartData():SongChartData + { + var result = songChartData.get(selectedVariation); + if (result == null) + { + result = new SongChartData(1.0, [], []); + songChartData.set(selectedVariation, result); + } + return result; + } + + function set_currentSongChartData(value:SongChartData):SongChartData + { + songChartData.set(selectedVariation, value); + return value; + } + + /** + * Convenience property to get (and set) the scroll speed for the current difficulty. + */ + var currentSongChartScrollSpeed(get, set):Float; + + function get_currentSongChartScrollSpeed():Float + { + var result = currentSongChartData.scrollSpeed.get(selectedDifficulty); + if (result == null) + { + // Initialize to the default value if not set. + currentSongChartData.scrollSpeed.set(selectedDifficulty, 1.0); + return 1.0; + } + return result; + } + + function set_currentSongChartScrollSpeed(value:Float):Float + { + currentSongChartData.scrollSpeed.set(selectedDifficulty, value); + return value; + } + + /** + * Convenience property to get the note data for the current difficulty. + */ + var currentSongChartNoteData(get, set):Array; + + function get_currentSongChartNoteData():Array + { + var result = currentSongChartData.notes.get(selectedDifficulty); + if (result == null) + { + // Initialize to the default value if not set. + result = []; + currentSongChartData.notes.set(selectedDifficulty, result); + return result; + } + return result; + } + + function set_currentSongChartNoteData(value:Array):Array + { + currentSongChartData.notes.set(selectedDifficulty, value); + return value; + } + + /** + * Convenience property to get the event data for the current difficulty. + */ + var currentSongChartEventData(get, set):Array; + + function get_currentSongChartEventData():Array + { + if (currentSongChartData.events == null) + { + // Initialize to the default value if not set. + currentSongChartData.events = []; + } + return currentSongChartData.events; + } + + function set_currentSongChartEventData(value:Array):Array + { + currentSongChartData.events = value; + return value; + } + + public var currentSongNoteSkin(get, set):String; + + function get_currentSongNoteSkin():String + { + if (currentSongMetadata.playData.noteSkin == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.noteSkin = 'Normal'; + } + return currentSongMetadata.playData.noteSkin; + } + + function set_currentSongNoteSkin(value:String):String + { + return currentSongMetadata.playData.noteSkin = value; + } + + var currentSongStage(get, set):String; + + function get_currentSongStage():String + { + if (currentSongMetadata.playData.stage == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.stage = 'mainStage'; + } + return currentSongMetadata.playData.stage; + } + + function set_currentSongStage(value:String):String + { + return currentSongMetadata.playData.stage = value; + } + + var currentSongName(get, set):String; + + function get_currentSongName():String + { + if (currentSongMetadata.songName == null) + { + // Initialize to the default value if not set. + currentSongMetadata.songName = 'New Song'; + } + return currentSongMetadata.songName; + } + + function set_currentSongName(value:String):String + { + return currentSongMetadata.songName = value; + } + + var currentSongId(get, null):String; + + function get_currentSongId():String + { + return currentSongName.toLowerKebabCase(); + } + + var currentSongArtist(get, set):String; + + function get_currentSongArtist():String + { + if (currentSongMetadata.artist == null) + { + // Initialize to the default value if not set. + currentSongMetadata.artist = 'Unknown'; + } + return currentSongMetadata.artist; + } + + function set_currentSongArtist(value:String):String + { + return currentSongMetadata.artist = value; + } + + /** + * RENDER OBJECTS + */ + // ============================== + + /** + * The IMAGE used for the grid. Updated by ChartEditorThemeHandler. + */ + var gridBitmap:BitmapData; + + /** + * The IMAGE used for the selection squares. Updated by ChartEditorThemeHandler. + * Used two ways: + * 1. A sprite is given this bitmap and placed over selected notes. + * 2. The image is split and used for a 9-slice sprite for the selection box. + */ + var selectionSquareBitmap:BitmapData = null; + + /** + * The tiled sprite used to display the grid. + * The height is the length of the song, and scrolling is done by simply the sprite. + */ + var gridTiledSprite:FlxSprite; + + /** + * The playhead representing the current position in the song. + * Can move around on the grid independently of the view. + */ + var gridPlayhead:FlxSpriteGroup; + + var gridPlayheadScrollArea:FlxSprite; + + /** + * A sprite used to indicate the note that will be placed on click. + */ + var gridGhostNote:ChartEditorNoteSprite; + + /** + * A sprite used to indicate the event that will be placed on click. + */ + var gridGhostEvent:ChartEditorEventSprite; + + /** + * The waveform which (optionally) displays over the grid, underneath the notes and playhead. + */ + var gridSpectrogram:PolygonSpectogram; + + /** + * The rectangle used for the note preview area. + * Should span the full height of the song. We scribble on this to draw the preview. + */ + var notePreviewBitmap:BitmapData; + + /** + * The sprite used to display the note preview area. + * We move this up and down to scroll the preview. + */ + var notePreviewSprite:FlxSprite; + + /** + * The rectangular sprite used for rendering the selection box. + * Uses a 9-slice to stretch the selection box to the correct size without warping. + */ + var selectionBoxSprite:FlxSliceSprite; + + /** + * The opponent's health icon. + */ + var healthIconDad:HealthIcon; + + /** + * The player's health icon. + */ + var healthIconBF:HealthIcon; + + /** + * The purple background sprite. + */ + var menuBG:FlxSprite; + + /** + * The sprite group containing the note graphics. + * Only displays a subset of the data from `currentSongChartNoteData`, + * and kills notes that are off-screen to be recycled later. + */ + var renderedNotes:FlxTypedSpriteGroup; + + /** + * The sprite group containing the song events. + * Only displays a subset of the data from `currentSongChartEventData`, + * and kills events that are off-screen to be recycled later. + */ + var renderedEvents:FlxTypedSpriteGroup; + + var renderedSelectionSquares:FlxTypedSpriteGroup; + + var notifBar:SideBar; + var playbarHead:Slider; + + public function new() + { + // Load the HaxeUI XML file. + super(CHART_EDITOR_LAYOUT); + } + + override function create() + { + // Get rid of any music from the previous state. + FlxG.sound.music.stop(); + + buildDefaultSongData(); + + buildBackground(); + + currentTheme = ChartEditorTheme.Light; + + buildGrid(); + buildSelectionBox(); + + // Add the HaxeUI components after the grid so they're on top. + super.create(); + buildAdditionalUI(); + + // Setup the onClick listeners for the UI after it's been created. + setupUIListeners(); + + setupAutoSave(); + + // TODO: We should be loading the music later when the user requests it. + // loadDefaultMusic(); + + // TODO: Change to false. + var canCloseInitialDialog = true; + ChartEditorDialogHandler.openWelcomeDialog(this, canCloseInitialDialog); + } + + function buildDefaultSongData() + { + selectedVariation = Constants.DEFAULT_VARIATION; + selectedDifficulty = Constants.DEFAULT_DIFFICULTY; + + // Initialize the song metadata. + songMetadata = new Map(); + + // Initialize the song chart data. + songChartData = new Map(); + + audioVocalTrackGroup = new VocalGroup(); + } + + /** + * Builds and displays the background sprite. + */ + function buildBackground() + { + menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); + add(menuBG); + + menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); + menuBG.updateHitbox(); + menuBG.screenCenter(); + menuBG.scrollFactor.set(0, 0); + } + + /** + * Builds and displays the chart editor grid, including the playhead and cursor. + */ + function buildGrid() + { + gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true); + gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid. + gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar. + add(gridTiledSprite); + + gridGhostNote = new ChartEditorNoteSprite(this); + gridGhostNote.alpha = 0.6; + gridGhostNote.noteData = new SongNoteData(-1, -1, 0, ""); + gridGhostNote.visible = false; + add(gridGhostNote); + + gridGhostEvent = new ChartEditorEventSprite(this); + gridGhostEvent.alpha = 0.6; + gridGhostEvent.eventData = new SongEventData(-1, "", {}); + gridGhostEvent.visible = false; + add(gridGhostEvent); + + buildNoteGroup(); + + gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH, + MENU_BAR_HEIGHT).makeGraphic(PLAYHEAD_SCROLL_AREA_WIDTH, FlxG.height - MENU_BAR_HEIGHT, PLAYHEAD_SCROLL_AREA_COLOR); + add(gridPlayheadScrollArea); + + // The playhead that show the current position in the song. + gridPlayhead = new FlxSpriteGroup(); + add(gridPlayhead); + + var playheadWidth = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2); + var playheadBaseYPos = MENU_BAR_HEIGHT + GRID_TOP_PAD; + gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos); + var playheadSprite = new FlxSprite().makeGraphic(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR); + playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH; + playheadSprite.y = 0; + gridPlayhead.add(playheadSprite); + + var playheadBlock = ChartEditorThemeHandler.buildPlayheadBlock(); + playheadBlock.x = -PLAYHEAD_SCROLL_AREA_WIDTH; + playheadBlock.y = -PLAYHEAD_HEIGHT / 2; + gridPlayhead.add(playheadBlock); + + // Character icons. + healthIconDad = new HealthIcon('dad'); + healthIconDad.autoUpdate = false; + healthIconDad.size.set(0.5, 0.5); + healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5); + healthIconDad.y = gridTiledSprite.y + 5; + add(healthIconDad); + + healthIconBF = new HealthIcon('bf'); + healthIconBF.autoUpdate = false; + healthIconBF.size.set(0.5, 0.5); + healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; + healthIconBF.y = gridTiledSprite.y + 5; + healthIconBF.flipX = true; + add(healthIconBF); + } + + function buildSelectionBox() + { + selectionBoxSprite.scrollFactor.set(0, 0); + add(selectionBoxSprite); + + setSelectionBoxBounds(); + } + + function setSelectionBoxBounds(?bounds:FlxRect = null) + { + if (bounds == null) + { + selectionBoxSprite.visible = false; + selectionBoxSprite.x = -9999; + selectionBoxSprite.y = -9999; + } + else + { + selectionBoxSprite.visible = true; + selectionBoxSprite.x = bounds.x; + selectionBoxSprite.y = bounds.y; + selectionBoxSprite.width = bounds.width; + selectionBoxSprite.height = bounds.height; + } + } + + function buildSpectrogram(target:FlxSound) + { + gridSpectrogram = new PolygonSpectogram(target, SPECTROGRAM_COLOR, FlxG.height / 2, Math.floor(FlxG.height / 2)); + // Halfway through the grid. + // gridSpectrogram.x = gridTiledSprite.x + STRUMLINE_SIZE * GRID_SIZE; + // gridSpectrogram.y = gridTiledSprite.y; + gridSpectrogram.x = 200; + gridSpectrogram.y = 200; + gridSpectrogram.visType = STATIC; // We move the spectrogram manually. + gridSpectrogram.waveAmplitude = 50; + gridSpectrogram.scrollFactor.set(0, 0); + add(gridSpectrogram); + } + + /** + * Builds the group that will hold all the notes. + */ + function buildNoteGroup() + { + renderedNotes = new FlxTypedSpriteGroup(); + renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); + add(renderedNotes); + + renderedEvents = new FlxTypedSpriteGroup(); + renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y); + add(renderedEvents); + + renderedSelectionSquares = new FlxTypedSpriteGroup(); + renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y); + add(renderedSelectionSquares); + } + + var playbarHeadLayout:Component; + + function buildAdditionalUI():Void + { + notifBar = cast buildComponent(CHART_EDITOR_NOTIFBAR_LAYOUT); + add(notifBar); + + playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT); + + playbarHeadLayout.width = FlxG.width - 8; + playbarHeadLayout.height = 10; + playbarHeadLayout.x = 4; + playbarHeadLayout.y = FlxG.height - 48 - 8; + + playbarHead = playbarHeadLayout.findComponent('playbarHead', Slider); + playbarHead.allowFocus = false; + playbarHead.width = FlxG.width; + playbarHead.height = 10; + playbarHead.styleString = "padding-left: 0px; padding-right: 0px; border-left: 0px; border-right: 0px;"; + + playbarHead.onDragStart = function(_:DragEvent) + { + playbarHeadDragging = true; + + // If we were dragging the playhead while the song was playing, resume playing. + if (audioInstTrack != null && audioInstTrack.playing) + { + playbarHeadDraggingWasPlaying = true; + stopAudioPlayback(); + } + else + { + playbarHeadDraggingWasPlaying = false; + } + } + + playbarHead.onDragEnd = function(_:DragEvent) + { + playbarHeadDragging = false; + + // Set the song position to where the playhead was moved to. + scrollPositionInPixels = songLengthInPixels * (playbarHead.value / 100); + // Update the conductor and audio tracks to match. + moveSongToScrollPosition(); + + // If we were dragging the playhead while the song was playing, resume playing. + if (playbarHeadDraggingWasPlaying) + { + playbarHeadDraggingWasPlaying = false; + startAudioPlayback(); + } + } + + // add(playbarHeadLayout); + } + + /** + * Sets up the onClick listeners for the UI. + */ + function setupUIListeners():Void + { + // Add functionality to the playbar. + + addUIClickListener('playbarPlay', (event:MouseEvent) -> toggleAudioPlayback()); + addUIClickListener('playbarStart', (event:MouseEvent) -> playbarButtonPressed = 'playbarStart'); + addUIClickListener('playbarBack', (event:MouseEvent) -> playbarButtonPressed = 'playbarBack'); + addUIClickListener('playbarForward', (event:MouseEvent) -> playbarButtonPressed = 'playbarForward'); + addUIClickListener('playbarEnd', (event:MouseEvent) -> playbarButtonPressed = 'playbarEnd'); + + // Add functionality to the menu items. + + addUIClickListener('menubarItemNewChart', (event:MouseEvent) -> ChartEditorDialogHandler.openWelcomeDialog(this, true)); + addUIClickListener('menubarItemSaveChartAs', (event:MouseEvent) -> exportAllSongData()); + addUIClickListener('menubarItemLoadInst', (event:MouseEvent) -> ChartEditorDialogHandler.openUploadInstDialog(this, true)); + + addUIClickListener('menubarItemUndo', (event:MouseEvent) -> undoLastCommand()); + + addUIClickListener('menubarItemRedo', (event:MouseEvent) -> redoLastCommand()); + + addUIClickListener('menubarItemCopy', (event:MouseEvent) -> + { + // Doesn't use a command because it's not undoable. + SongDataUtils.writeItemsToClipboard({ + notes: SongDataUtils.buildNoteClipboard(currentNoteSelection), + events: SongDataUtils.buildEventClipboard(currentEventSelection), + }); + }); + + addUIClickListener('menubarItemCut', (event:MouseEvent) -> + { + performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection)); + }); + + addUIClickListener('menubarItemPaste', (event:MouseEvent) -> + { + performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs)); + }); + + addUIClickListener('menubarItemDelete', (event:MouseEvent) -> + { + if (currentNoteSelection.length > 0 && currentEventSelection.length > 0) + { + performCommand(new RemoveItemsCommand(currentNoteSelection, currentEventSelection)); + } + else if (currentNoteSelection.length > 0) + { + performCommand(new RemoveNotesCommand(currentNoteSelection)); + } + else if (currentEventSelection.length > 0) + { + performCommand(new RemoveEventsCommand(currentEventSelection)); + } + else + { + // Do nothing??? + } + }); + + addUIClickListener('menubarItemSelectAll', (event:MouseEvent) -> + { + performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection)); + }); + + addUIClickListener('menubarItemSelectInverse', (event:MouseEvent) -> + { + performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection)); + }); + + addUIClickListener('menubarItemSelectNone', (event:MouseEvent) -> + { + performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); + }); + + addUIClickListener('menubarItemSelectRegion', (event:MouseEvent) -> { + // TODO: Implement this. + }); + + addUIClickListener('menubarItemSelectBeforeCursor', (event:MouseEvent) -> { + // TODO: Implement this. + }); + + addUIClickListener('menubarItemSelectAfterCursor', (event:MouseEvent) -> { + // TODO: Implement this. + }); + + addUIClickListener('menubarItemAbout', (event:MouseEvent) -> ChartEditorDialogHandler.openAboutDialog(this)); + + addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> ChartEditorDialogHandler.openUserGuideDialog(this)); + + addUIChangeListener('menubarItemDownscroll', (event:UIEvent) -> + { + isViewDownscroll = event.value; + }); + setUICheckboxSelected('menubarItemDownscroll', isViewDownscroll); + + addUIChangeListener('menuBarItemThemeLight', (event:UIEvent) -> + { + if (event.target.value) + currentTheme = ChartEditorTheme.Light; + }); + setUICheckboxSelected('menuBarItemThemeLight', currentTheme == ChartEditorTheme.Light); + + addUIChangeListener('menuBarItemThemeDark', (event:UIEvent) -> + { + if (event.target.value) + currentTheme = ChartEditorTheme.Dark; + }); + setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark); + + addUIChangeListener('menubarItemMetronomeEnabled', (event:UIEvent) -> + { + shouldPlayMetronome = event.value; + }); + setUICheckboxSelected('menubarItemMetronomeEnabled', shouldPlayMetronome); + + addUIChangeListener('menubarItemPlayerHitsounds', (event:UIEvent) -> + { + hitsoundsEnabledPlayer = event.value; + }); + setUICheckboxSelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer); + + addUIChangeListener('menubarItemOpponentHitsounds', (event:UIEvent) -> + { + hitsoundsEnabledOpponent = event.value; + }); + setUICheckboxSelected('menubarItemOpponentHitsounds', hitsoundsEnabledOpponent); + + var instVolumeLabel:Label = findComponent('menubarLabelVolumeInstrumental', Label); + addUIChangeListener('menubarItemVolumeInstrumental', (event:UIEvent) -> + { + var volume:Float = event.value / 100.0; + if (audioInstTrack != null) + audioInstTrack.volume = volume; + instVolumeLabel.text = 'Instrumental - ${Std.int(event.value)}%'; + }); + + var vocalsVolumeLabel:Label = findComponent('menubarLabelVolumeVocals', Label); + addUIChangeListener('menubarItemVolumeVocals', (event:UIEvent) -> + { + var volume:Float = event.value / 100.0; + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.volume = volume; + vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%'; + }); + + var playbackSpeedLabel:Label = findComponent('menubarLabelPlaybackSpeed', Label); + addUIChangeListener('menubarItemPlaybackSpeed', (event:UIEvent) -> + { + var pitch = event.value * 2.0 / 100.0; + #if FLX_PITCH + if (audioInstTrack != null) + audioInstTrack.pitch = pitch; + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.pitch = pitch; + #end + playbackSpeedLabel.text = 'Playback Speed - ${Std.int(event.value * 100) / 100}x'; + }); + + addUIChangeListener('menubarItemToggleToolboxTools', (event:UIEvent) -> + { + ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT, event.value); + }); + // setUICheckboxSelected('menubarItemToggleToolboxTools', true); + addUIChangeListener('menubarItemToggleToolboxNotes', (event:UIEvent) -> + { + ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value); + }); + addUIChangeListener('menubarItemToggleToolboxEvents', (event:UIEvent) -> + { + ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value); + }); + addUIChangeListener('menubarItemToggleToolboxDifficulty', (event:UIEvent) -> + { + ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value); + }); + addUIChangeListener('menubarItemToggleToolboxMetadata', (event:UIEvent) -> + { + ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value); + }); + addUIChangeListener('menubarItemToggleToolboxCharacters', (event:UIEvent) -> + { + ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT, event.value); + }); + addUIChangeListener('menubarItemToggleToolboxPlayerPreview', (event:UIEvent) -> + { + ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value); + }); + addUIChangeListener('menubarItemToggleToolboxOpponentPreview', (event:UIEvent) -> + { + ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value); + }); + + // TODO: Pass specific HaxeUI components to add context menus to them. + registerContextMenu(null, Paths.ui('chart-editor/context/test')); + } + + /** + * Setup timers and listerners to handle auto-save. + */ + function setupAutoSave() + { + WindowUtil.windowExit.add(onWindowClose); + saveDataDirty = false; + } + + /** + * Called after 5 minutes without saving. + */ + function autoSave() + { + saveDataDirty = false; + + // Auto-save the chart. + + #if html5 + // Auto-save to local storage. + #else + // Auto-save to temp file. + exportAllSongData(true, true); + #end + } + + function onWindowClose(exitCode:Int) + { + trace('Window exited with exit code: $exitCode'); + trace('Should save chart? $saveDataDirty'); + + if (saveDataDirty) + { + exportAllSongData(true); + } + } + + function cleanupAutoSave() + { + WindowUtil.windowExit.remove(onWindowClose); + } + + public override function update(elapsed:Float) + { + // dispatchEvent gets called here. + super.update(elapsed); + + FlxG.mouse.visible = true; + + // These ones happen even if the modal dialog is open. + handleMusicPlayback(); + handleNoteDisplay(); + + // These ones only happen if the modal dialog is not open. + handleScrollKeybinds(); + // handleZoom(); + // handleSnap(); + handleCursor(); + + handleMenubar(); + handleToolboxes(); + handlePlaybar(); + handlePlayhead(); + + handleFileKeybinds(); + handleEditKeybinds(); + handleViewKeybinds(); + handleHelpKeybinds(); + + // DEBUG + #if debug + if (FlxG.keys.justPressed.F) + { + // This breaks the layout don't use it. + // showNotification('Hi there :)'); + + // autoSave(); + } + + if (FlxG.keys.justPressed.E) + { + currentSongMetadata.timeChanges[0].timeSignatureNum = (currentSongMetadata.timeChanges[0].timeSignatureNum == 4 ? 3 : 4); + } + #end + + // Right align the BF health icon. + + // Base X position to the right of the grid. + var baseHealthIconXPos = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; + // Will be 0 when not bopping. When bopping, will increase to push the icon left. + var healthIconOffset = healthIconBF.width - (HealthIcon.HEALTH_ICON_SIZE * 0.5); + healthIconBF.x = baseHealthIconXPos - healthIconOffset; + } + + /** + * Beat hit while the song is playing. + */ + override function beatHit():Bool + { + // dispatchEvent gets called here. + if (!super.beatHit()) + return false; + + if (shouldPlayMetronome && audioInstTrack.playing) + { + playMetronomeTick(Conductor.currentBeat % 4 == 0); + } + + return true; + } + + /** + * Step hit while the song is playing. + */ + override function stepHit():Bool + { + // dispatchEvent gets called here. + if (!super.stepHit()) + return false; + + if (audioInstTrack.playing) + { + healthIconDad.onStepHit(Conductor.currentStep); + healthIconBF.onStepHit(Conductor.currentStep); + } + + // if (shouldPlayMetronome) + // playMetronomeTick(false); + + return true; + } + + /** + * Handle keybinds for scrolling the chart editor grid. + **/ + function handleScrollKeybinds() + { + // Don't scroll when the cursor is over the UI. + if (isCursorOverHaxeUI) + return; + + // Amount to scroll the grid. + var scrollAmount:Float = 0; + // Amount to scroll the playhead relative to the grid. + var playheadAmount:Float = 0; + + // Up Arrow = Scroll Up + if (FlxG.keys.justPressed.UP) + { + scrollAmount = -GRID_SIZE * 0.25; + } + // Down Arrow = Scroll Down + if (FlxG.keys.justPressed.DOWN) + { + scrollAmount = GRID_SIZE * 0.25; + } + + // PAGE UP = Jump Up 1 Measure + if (FlxG.keys.justPressed.PAGEUP) + { + scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure; + } + if (playbarButtonPressed == 'playbarBack') + { + playbarButtonPressed = ''; + scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure; + } + + // PAGE DOWN = Jump Down 1 Measure + if (FlxG.keys.justPressed.PAGEDOWN) + { + scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure; + } + if (playbarButtonPressed == 'playbarForward') + { + playbarButtonPressed = ''; + scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure; + } + + // Mouse Wheel = Scroll + if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL) + { + scrollAmount = -10 * FlxG.mouse.wheel; + } + + // Middle Mouse + Drag = Scroll but move the playhead the same amount. + if (FlxG.mouse.pressedMiddle) + { + if (FlxG.mouse.deltaY != 0) + { + // Scroll down by the amount dragged. + scrollAmount += -FlxG.mouse.deltaY; + // Move the playhead by the same amount in the other direction so it is stationary. + playheadAmount += FlxG.mouse.deltaY; + } + } + + // SHIFT + Scroll = Scroll Fast + if (FlxG.keys.pressed.SHIFT) + { + scrollAmount *= 5; + } + // CONTROL + Scroll = Scroll Precise + if (FlxG.keys.pressed.CONTROL) + { + scrollAmount /= 10; + } + + // ALT = Move playhead instead. + if (FlxG.keys.pressed.ALT) + { + playheadAmount = scrollAmount; + scrollAmount = 0; + } + + // HOME = Scroll to Top + if (FlxG.keys.justPressed.HOME) + { + // Scroll amount is the difference between the current position and the top. + scrollAmount = 0 - this.scrollPositionInPixels; + playheadAmount = 0 - this.playheadPositionInPixels; + } + if (playbarButtonPressed == 'playbarStart') + { + playbarButtonPressed = ''; + scrollAmount = 0 - this.scrollPositionInPixels; + playheadAmount = 0 - this.playheadPositionInPixels; + } + + // END = Scroll to Bottom + if (FlxG.keys.justPressed.END) + { + // Scroll amount is the difference between the current position and the bottom. + scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; + } + if (playbarButtonPressed == 'playbarEnd') + { + playbarButtonPressed = ''; + scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; + } + + // Apply the scroll amount. + this.scrollPositionInPixels += scrollAmount; + this.playheadPositionInPixels += playheadAmount; + + // Resync the conductor and audio tracks. + if (scrollAmount != 0 || playheadAmount != 0) + moveSongToScrollPosition(); + } + + function handleZoom() + { + if (FlxG.keys.justPressed.MINUS) + { + currentZoomLevel /= 2; + + // Update the grid. + ChartEditorThemeHandler.updateTheme(this); + // Update the note positions. + noteDisplayDirty = true; + } + + if (FlxG.keys.justPressed.PLUS) + { + currentZoomLevel *= 2; + + // Update the grid. + ChartEditorThemeHandler.updateTheme(this); + // Update the note positions. + noteDisplayDirty = true; + } + } + + function handleSnap() + { + if (FlxG.keys.justPressed.LEFT) + { + noteSnapQuantIndex--; + } + + if (FlxG.keys.justPressed.RIGHT) + { + noteSnapQuantIndex++; + } + } + + /** + * Handle display of the mouse cursor. + */ + function handleCursor() + { + // Note: If a menu is open in HaxeUI, don't handle cursor behavior. + var shouldHandleCursor = !isCursorOverHaxeUI || (selectionBoxStartPos != null); + var eventColumn = (STRUMLINE_SIZE * 2 + 1) - 1; + + if (shouldHandleCursor) + { + var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite); + + // Cursor position relative to the grid. + var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x; + var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y; + + var overlapsSelectionBorder = overlapsGrid + && (cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) + || (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)) + || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)); + + if (FlxG.mouse.justPressed) + { + if (FlxG.mouse.overlaps(gridPlayheadScrollArea)) + { + gridPlayheadScrollAreaPressed = true; + } + else if (!overlapsGrid || overlapsSelectionBorder) + { + selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY); + } + } + + if (gridPlayheadScrollAreaPressed) + { + Cursor.cursorMode = Grabbing; + } + else if (FlxG.mouse.overlaps(gridPlayheadScrollArea)) + { + Cursor.cursorMode = Pointer; + } + else + { + Cursor.cursorMode = Default; + } + + if (gridPlayheadScrollAreaPressed && FlxG.mouse.released) + { + gridPlayheadScrollAreaPressed = false; + } + + if (gridPlayheadScrollAreaPressed) + { + // Clicked on the playhead scroll area. + // Move the playhead to the cursor position. + this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD; + moveSongToScrollPosition(); + } + + // Cursor position snapped to the grid. + + // The song position of the cursor, in steps. + var cursorFractionalStep:Float = cursorY / GRID_SIZE / (16 / noteSnapQuant); + var cursorStep:Int = Std.int(Math.floor(cursorFractionalStep)); + var cursorMs:Float = cursorStep * Conductor.stepCrochet * (16 / noteSnapQuant); + // The direction value for the column at the cursor. + var cursorColumn:Int = Math.floor(cursorX / GRID_SIZE); + if (cursorColumn < 0) + cursorColumn = 0; + if (cursorColumn >= (STRUMLINE_SIZE * 2 + 1 - 1)) + { + // Don't invert the event column. + cursorColumn = (STRUMLINE_SIZE * 2 + 1 - 1); + } + else + { + // Invert player and opponent columns. + if (cursorColumn >= STRUMLINE_SIZE) + { + cursorColumn -= STRUMLINE_SIZE; + } + else + { + cursorColumn += STRUMLINE_SIZE; + } + } + + if (selectionBoxStartPos != null) + { + var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x; + var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y; + + var hasDraggedMouse:Bool = Math.abs(cursorX - cursorXStart) > DRAG_THRESHOLD || Math.abs(cursorY - cursorYStart) > DRAG_THRESHOLD; + + // Determine if we dragged the mouse at all. + if (hasDraggedMouse) + { + // Handle releasing the selection box. + if (FlxG.mouse.justReleased) + { + // We released the mouse. Select the notes in the box. + var cursorFractionalStepStart:Float = cursorYStart / GRID_SIZE; + var cursorStepStart:Int = Math.floor(cursorFractionalStepStart); + var cursorMsStart:Float = cursorStepStart * Conductor.stepCrochet; + var cursorColumnBase:Int = Math.floor(cursorX / GRID_SIZE); + var cursorColumnBaseStart:Int = Math.floor(cursorXStart / GRID_SIZE); + + // Since this selects based on noteData directly, + // we don't need to specifically exclude sustain pieces. + + // This logic is gross because the columns go 4567-0123-8. + // We build a list of columns to select. + var columnStart:Int = Std.int(Math.min(cursorColumnBase, cursorColumnBaseStart)); + var columnEnd:Int = Std.int(Math.max(cursorColumnBase, cursorColumnBaseStart)); + var columns:Array = [for (i in columnStart...(columnEnd + 1)) i].map(function(i:Int):Int + { + if (i >= eventColumn) + { + // Don't invert the event column. + return eventColumn; + } + else if (i >= STRUMLINE_SIZE) + { + // Invert the player columns. + return i - STRUMLINE_SIZE; + } + else if (i >= 0) + { + // Invert the opponent columns. + return i + STRUMLINE_SIZE; + } + else + { + // Minimum of 0. + return 0; + } + }); + + if (columns.length > 0) + { + var notesToSelect:Array = currentSongChartNoteData; + notesToSelect = SongDataUtils.getNotesInTimeRange(notesToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs)); + notesToSelect = SongDataUtils.getNotesWithData(notesToSelect, columns); + + var eventsToSelect:Array = []; + + if (columns.indexOf(eventColumn) != -1) + { + // The drag selection included the event column. + eventsToSelect = currentSongChartEventData; + eventsToSelect = SongDataUtils.getEventsInTimeRange(eventsToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs)); + } + + if (notesToSelect.length > 0 || eventsToSelect.length > 0) + { + if (FlxG.keys.pressed.CONTROL) + { + // Add to the selection. + performCommand(new SelectItemsCommand(notesToSelect, eventsToSelect)); + } + else + { + // Set the selection. + performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect, currentNoteSelection, currentEventSelection)); + } + } + else + { + // We made a selection box, but it didn't select anything. + } + } + else + { + // We made a selection box, but it didn't select any columns. + } + + // Clear the selection box. + selectionBoxStartPos = null; + setSelectionBoxBounds(); + } + else + { + // Render the selection box. + var selectionRect = new FlxRect(); + selectionRect.x = Math.min(FlxG.mouse.screenX, selectionBoxStartPos.x); + selectionRect.y = Math.min(FlxG.mouse.screenY, selectionBoxStartPos.y); + selectionRect.width = Math.abs(FlxG.mouse.screenX - selectionBoxStartPos.x); + selectionRect.height = Math.abs(FlxG.mouse.screenY - selectionBoxStartPos.y); + setSelectionBoxBounds(selectionRect); + } + } + else if (FlxG.mouse.justReleased) + { + // Clear the selection box. + selectionBoxStartPos = null; + setSelectionBoxBounds(); + + if (overlapsGrid) + { + // We clicked on the grid without moving the mouse. + + // Find the first note that is at the cursor position. + var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool + { + // If note.alive is false, the note is dead and awaiting recycling. + return note.alive && FlxG.mouse.overlaps(note); + }); + var highlightedEvent:ChartEditorEventSprite = null; + if (highlightedNote == null) + { + highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool + { + return event.alive && FlxG.mouse.overlaps(event); + }); + } + + if (FlxG.keys.pressed.CONTROL) + { + if (highlightedNote != null) + { + // Handle the case of clicking on a sustain piece. + highlightedNote = highlightedNote.getBaseNoteSprite(); + // Control click to select/deselect an individual note. + if (isNoteSelected(highlightedNote.noteData)) + { + performCommand(new DeselectItemsCommand([highlightedNote.noteData], [])); + } + else + { + performCommand(new SelectItemsCommand([highlightedNote.noteData], [])); + } + } + else if (highlightedEvent != null) + { + // Control click to select/deselect an individual note. + if (isEventSelected(highlightedEvent.eventData)) + { + performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData])); + } + else + { + performCommand(new SelectItemsCommand([], [highlightedEvent.eventData])); + } + } + else + { + // Do nothing if you control-clicked on an empty space. + } + } + else + { + if (highlightedNote != null) + { + // Click a note to select it. + performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection)); + } + else if (highlightedEvent != null) + { + // Click an event to select it. + performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection)); + } + else + { + // Click on an empty space to deselect everything. + performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); + } + } + } + else + { + // If we clicked and released outside the grid, do nothing. + } + } + } + else if (currentPlaceNoteData != null) + { + // Handle extending the note as you drag. + + // Since use Math.floor and stepCrochet here, the hold notes will be beat snapped. + var dragLengthSteps:Float = Math.floor((cursorMs - currentPlaceNoteData.time) / Conductor.stepCrochet); + + // Without this, the newly placed note feels too short compared to the user's input. + var INCREMENT:Float = 1.0; + var dragLengthMs:Float = (dragLengthSteps + INCREMENT) * Conductor.stepCrochet; + + // TODO: Add and update some sort of preview? + + if (FlxG.mouse.justReleased) + { + if (dragLengthSteps > 0) + { + // Apply the new length. + performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs)); + } + + // Finished dragging. Release the note. + currentPlaceNoteData = null; + } + } + else + { + if (FlxG.mouse.justPressed) + { + // Just clicked to place a note. + if (overlapsGrid && !overlapsSelectionBorder) + { + // We clicked on the grid without moving the mouse. + + // Find the first note that is at the cursor position. + var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool + { + // If note.alive is false, the note is dead and awaiting recycling. + return note.alive && FlxG.mouse.overlaps(note); + }); + var highlightedEvent:ChartEditorEventSprite = null; + if (highlightedNote == null) + { + highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool + { + // If event.alive is false, the event is dead and awaiting recycling. + return event.alive && FlxG.mouse.overlaps(event); + }); + } + + if (FlxG.keys.pressed.CONTROL) + { + // Control click to select/deselect an individual note. + if (highlightedNote != null) + { + if (isNoteSelected(highlightedNote.noteData)) + { + performCommand(new DeselectItemsCommand([highlightedNote.noteData], [])); + } + else + { + performCommand(new SelectItemsCommand([highlightedNote.noteData], [])); + } + } + else if (highlightedEvent != null) + { + if (isEventSelected(highlightedEvent.eventData)) + { + performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData])); + } + else + { + performCommand(new SelectItemsCommand([], [highlightedEvent.eventData])); + } + } + else + { + // Do nothing when control clicking nothing. + } + } + else + { + if (highlightedNote != null) + { + // Click a note to select it. + performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection)); + } + else if (highlightedEvent != null) + { + // Click an event to select it. + performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection)); + } + else + { + // Click a blank space to place a note and select it. + + if (cursorColumn == eventColumn) + { + // Create an event and place it in the chart. + // TODO: Figure out configuring event data. + var newEventData:SongEventData = new SongEventData(cursorMs, selectedEventKind, selectedEventData); + + performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL)); + } + else + { + // Create a note and place it in the chart. + var newNoteData:SongNoteData = new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind); + + performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); + + currentPlaceNoteData = newNoteData; + } + } + } + } + else + { + // If we clicked and released outside the grid, do nothing. + } + } + + var rightMouseUpdated:Bool = (FlxG.mouse.justPressedRight) + || (FlxG.mouse.pressedRight && (FlxG.mouse.deltaX > 0 || FlxG.mouse.deltaY > 0)); + if (rightMouseUpdated && overlapsGrid) + { + // We right clicked on the grid. + + // Find the first note that is at the cursor position. + var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool + { + // If note.alive is false, the note is dead and awaiting recycling. + return note.alive && FlxG.mouse.overlaps(note); + }); + var highlightedEvent:ChartEditorEventSprite = null; + if (highlightedNote == null) + { + highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool + { + // If event.alive is false, the event is dead and awaiting recycling. + return event.alive && FlxG.mouse.overlaps(event); + }); + } + + if (highlightedNote != null) + { + // Handle the case of clicking on a sustain piece. + highlightedNote = highlightedNote.getBaseNoteSprite(); + // Remove the note. + performCommand(new RemoveNotesCommand([highlightedNote.noteData])); + } + else if (highlightedEvent != null) + { + // Remove the event. + performCommand(new RemoveEventsCommand([highlightedEvent.eventData])); + } + else + { + // Right clicked on nothing. + } + } + + // Handle grid cursor. + if (overlapsGrid && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed) + { + Cursor.cursorMode = Pointer; + + // Indicate that we can place a note here. + + if (cursorColumn == eventColumn) + { + gridGhostEvent.visible = true; + gridGhostNote.visible = false; + + if (selectedEventKind != gridGhostEvent.eventData.event) + { + gridGhostEvent.eventData.event = selectedEventKind; + } + + gridGhostEvent.eventData.time = cursorMs; + gridGhostEvent.updateEventPosition(renderedEvents); + } + else + { + gridGhostEvent.visible = false; + gridGhostNote.visible = true; + + if (cursorColumn != gridGhostNote.noteData.data || selectedNoteKind != gridGhostNote.noteData.kind) + { + gridGhostNote.noteData.kind = selectedNoteKind; + gridGhostNote.noteData.data = cursorColumn; + gridGhostNote.playNoteAnimation(); + } + + gridGhostNote.noteData.time = cursorMs; + gridGhostNote.updateNotePosition(renderedNotes); + } + + // gridCursor.visible = true; + // // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square. + // gridCursor.x = Math.floor(cursorX / GRID_SIZE) * GRID_SIZE + gridTiledSprite.x + (GRID_SELECTION_BORDER_WIDTH / 2); + // gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y + (GRID_SELECTION_BORDER_WIDTH / 2); + } + else + { + gridGhostNote.visible = false; + gridGhostEvent.visible = false; + Cursor.cursorMode = Default; + } + } + } + else + { + gridGhostNote.visible = false; + gridGhostEvent.visible = false; + } + + if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default) + { + Cursor.cursorMode = Pointer; + } + } + + /** + * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`. + */ + function handleNoteDisplay() + { + if (noteDisplayDirty) + { + noteDisplayDirty = false; + + // Update for whether downscroll is enabled. + renderedNotes.flipX = (isViewDownscroll); + + // Calculate the view bounds. + var viewAreaTop:Float = this.scrollPositionInPixels - GRID_TOP_PAD; + var viewHeight:Float = (FlxG.height - MENU_BAR_HEIGHT); + var viewAreaBottom:Float = this.scrollPositionInPixels + viewHeight; + + // Remove notes that are no longer visible and list the ones that are. + var displayedNoteData:Array = []; + for (noteSprite in renderedNotes.members) + { + if (noteSprite == null || !noteSprite.exists || !noteSprite.visible) + continue; + + if (!noteSprite.isNoteVisible(viewAreaBottom, viewAreaTop)) + { + // This sprite is off-screen. + // Kill the note sprite and recycle it. + noteSprite.noteData = null; + } + else if (currentSongChartNoteData.indexOf(noteSprite.noteData) == -1) + { + // This note was deleted. + // Kill the note sprite and recycle it. + noteSprite.noteData = null; + } + else if (noteSprite.noteData.length > 0 && (noteSprite.parentNoteSprite == null && noteSprite.childNoteSprite == null)) + { + // Note was extended. + // Kill the note sprite and recycle it. + noteSprite.noteData = null; + } + else if (noteSprite.noteData.length == 0 && (noteSprite.parentNoteSprite != null || noteSprite.childNoteSprite != null)) + { + // Note was shortened. + // Kill the note sprite and recycle it. + noteSprite.noteData = null; + } + else + { + // Note is already displayed and should remain displayed. + displayedNoteData.push(noteSprite.noteData); + + // Update the note sprite's position. + noteSprite.updateNotePosition(renderedNotes); + } + } + + // Remove events that are no longer visible and list the ones that are. + var displayedEventData:Array = []; + for (eventSprite in renderedEvents.members) + { + if (eventSprite == null || !eventSprite.exists || !eventSprite.visible) + continue; + + if (!eventSprite.isEventVisible(viewAreaBottom, viewAreaTop)) + { + // This sprite is off-screen. + // Kill the event sprite and recycle it. + eventSprite.eventData = null; + } + else if (currentSongChartEventData.indexOf(eventSprite.eventData) == -1) + { + // This event was deleted. + // Kill the event sprite and recycle it. + eventSprite.eventData = null; + } + else + { + // Event is already displayed and should remain displayed. + displayedEventData.push(eventSprite.eventData); + + // Update the event sprite's position. + eventSprite.updateEventPosition(renderedEvents); + } + } + + // Add notes that are now visible. + for (noteData in currentSongChartNoteData) + { + // Remember if we are already displaying this note. + if (displayedNoteData.indexOf(noteData) != -1) + { + continue; + } + + // Get the position the note should be at. + var noteTimePixels:Float = noteData.time / Conductor.stepCrochet * GRID_SIZE; + + // Make sure the note appears when scrolling up. + var modifiedViewAreaTop = viewAreaTop - GRID_SIZE; + + if (noteTimePixels < modifiedViewAreaTop || noteTimePixels > viewAreaBottom) + continue; + + // Else, this note is visible and we need to render it! + + // Get a note sprite from the pool. + // If we can reuse a deleted note, do so. + // If a new note is needed, call buildNoteSprite. + var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this)); + noteSprite.parentState = this; + + // The note sprite handles animation playback and positioning. + noteSprite.noteData = noteData; + + // Setting note data resets position relative to the grid so we fix that. + noteSprite.x += renderedNotes.x; + noteSprite.y += renderedNotes.y; + + if (noteSprite.noteData.length > 0) + { + // If the note is a hold, we need to make sure it's long enough. + var noteLengthMs:Float = noteSprite.noteData.length; + var noteLengthSteps:Float = (noteLengthMs / Conductor.stepCrochet); + var lastNoteSprite:ChartEditorNoteSprite = noteSprite; + + while (noteLengthSteps > 0) + { + if (noteLengthSteps <= 1.0) + { + // Last note in the hold. + // TODO: We may need to make it shorter and clip it visually. + } + + var nextNoteSprite:ChartEditorNoteSprite = renderedNotes.recycle(ChartEditorNoteSprite); + nextNoteSprite.parentState = this; + nextNoteSprite.parentNoteSprite = lastNoteSprite; + lastNoteSprite.childNoteSprite = nextNoteSprite; + + lastNoteSprite = nextNoteSprite; + + noteLengthSteps -= 1; + } + + // Make sure the last note sprite shows the end cap properly. + lastNoteSprite.childNoteSprite = null; + + // var noteLengthPixels:Float = (noteLengthMs / Conductor.stepCrochet + 1) * GRID_SIZE; + // add(new FlxSprite(noteSprite.x, noteSprite.y - renderedNotes.y + noteLengthPixels).makeGraphic(40, 2, 0xFFFF0000)); + } + } + + // Add events that are now visible. + for (eventData in currentSongChartEventData) + { + // Remember if we are already displaying this event. + if (displayedEventData.indexOf(eventData) != -1) + { + continue; + } + + // Get the position the event should be at. + var eventTimePixels:Float = eventData.time / Conductor.stepCrochet * GRID_SIZE; + + // Make sure the event appears when scrolling up. + var modifiedViewAreaTop = viewAreaTop - GRID_SIZE; + + if (eventTimePixels < modifiedViewAreaTop || eventTimePixels > viewAreaBottom) + continue; + + // Else, this event is visible and we need to render it! + + // Get an event sprite from the pool. + // If we can reuse a deleted event, do so. + // If a new event is needed, call buildEventSprite. + var eventSprite:ChartEditorEventSprite = renderedEvents.recycle(() -> new ChartEditorEventSprite(this)); + eventSprite.parentState = this; + + // The event sprite handles animation playback and positioning. + eventSprite.eventData = eventData; + + // Setting event data resets position relative to the grid so we fix that. + eventSprite.x += renderedEvents.x; + eventSprite.y += renderedEvents.y; + } + + // Destroy all existing selection squares. + for (member in renderedSelectionSquares.members) + { + // Killing the sprite is cheap because we can recycle it. + member.kill(); + } + + // Readd selection squares for selected notes. + // Recycle selection squares if possible. + for (noteSprite in renderedNotes.members) + { + if (isNoteSelected(noteSprite.noteData) && noteSprite.parentNoteSprite == null) + { + var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare); + + // Set the position and size (because we might be recycling one with bad values). + selectionSquare.x = noteSprite.x; + selectionSquare.y = noteSprite.y; + selectionSquare.width = noteSprite.width; + selectionSquare.height = noteSprite.height; + } + } + + for (eventSprite in renderedEvents.members) + { + if (isEventSelected(eventSprite.eventData)) + { + var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare); + + // Set the position and size (because we might be recycling one with bad values). + selectionSquare.x = eventSprite.x; + selectionSquare.y = eventSprite.y; + selectionSquare.width = eventSprite.width; + selectionSquare.height = eventSprite.height; + } + } + + // Sort the notes DESCENDING. This keeps the sustain behind the associated note. + renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); + + // Sort the events DESCENDING. This keeps the sustain behind the associated note. + renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); + } + } + + function buildSelectionSquare():FlxSprite + { + return new FlxSprite().loadGraphic(selectionSquareBitmap); + } + + /** + * Handles display elements for the playbar at the bottom. + */ + function handlePlaybar() + { + // Make sure the playbar is never nudged out of the correct spot. + playbarHeadLayout.x = 4; + playbarHeadLayout.y = FlxG.height - 48 - 8; + + var songPos = Conductor.songPosition; + var songRemaining = songLengthInMs - songPos; + + // Move the playhead to match the song position, if we aren't dragging it. + if (!playbarHeadDragging) + { + var songPosPercent:Float = songPos / songLengthInMs; + playbarHead.value = songPosPercent * 100; + } + + var songPosSeconds:String = Std.string(Math.floor((songPos / 1000) % 60)).lpad('0', 2); + var songPosMinutes:String = Std.string(Math.floor((songPos / 1000) / 60)).lpad('0', 2); + var songPosString:String = '${songPosMinutes}:${songPosSeconds}'; + + setUIValue('playbarSongPos', songPosString); + + var songRemainingSeconds:String = Std.string(Math.floor((songRemaining / 1000) % 60)).lpad('0', 2); + var songRemainingMinutes:String = Std.string(Math.floor((songRemaining / 1000) / 60)).lpad('0', 2); + var songRemainingString:String = '-${songRemainingMinutes}:${songRemainingSeconds}'; + + setUIValue('playbarSongRemaining', songRemainingString); + } + + /** + * Handle keybinds for File menu items. + */ + function handleFileKeybinds() + { + // CTRL + Q = Quit to Menu + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q) + { + FlxG.switchState(new MainMenuState()); + } + } + + /** + * Handle keybinds for edit menu items. + */ + function handleEditKeybinds() + { + // CTRL + Z = Undo + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Z) + { + undoLastCommand(); + } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.Z && !FlxG.keys.pressed.Y) + { + undoHeldTime += FlxG.elapsed; + } + else + { + undoHeldTime = 0; + } + if (undoHeldTime > RAPID_UNDO_DELAY + RAPID_UNDO_INTERVAL) + { + undoLastCommand(); + undoHeldTime -= RAPID_UNDO_INTERVAL; + } + + // CTRL + Y = Redo + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Y) + { + redoLastCommand(); + } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.Y && !FlxG.keys.pressed.Z) + { + redoHeldTime += FlxG.elapsed; + } + else + { + redoHeldTime = 0; + } + if (redoHeldTime > RAPID_UNDO_DELAY + RAPID_UNDO_INTERVAL) + { + redoLastCommand(); + redoHeldTime -= RAPID_UNDO_INTERVAL; + } + + // CTRL + C = Copy + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.C) + { + // Copy selected notes. + // We don't need a command for this since we can't undo it. + SongDataUtils.writeItemsToClipboard({ + notes: SongDataUtils.buildNoteClipboard(currentNoteSelection), + events: SongDataUtils.buildEventClipboard(currentEventSelection), + }); + } + + // CTRL + X = Cut + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.X) + { + // Cut selected notes. + performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection)); + } + + // CTRL + V = Paste + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.V) + { + // Paste notes from clipboard, at the playhead. + performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs)); + } + + // DELETE = Delete + if (FlxG.keys.justPressed.DELETE) + { + // Delete selected items. + if (currentNoteSelection.length > 0 && currentEventSelection.length > 0) + { + performCommand(new RemoveItemsCommand(currentNoteSelection, currentEventSelection)); + } + else if (currentNoteSelection.length > 0) + { + performCommand(new RemoveNotesCommand(currentNoteSelection)); + } + else if (currentEventSelection.length > 0) + { + performCommand(new RemoveEventsCommand(currentEventSelection)); + } + } + + // CTRL + A = Select All + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A) + { + // Select all items. + performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection)); + } + + // CTRL + I = Select Inverse + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I) + { + // Select unselected items and deselect selected items. + performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection)); + } + + // CTRL + D = Select None + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D) + { + // Deselect all items. + performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); + } + } + + /** + * Handle keybinds for View menu items. + */ + function handleViewKeybinds() {} + + /** + * Handle keybinds for Help menu items. + */ + function handleHelpKeybinds() + { + // F1 = Open Help + if (FlxG.keys.justPressed.F1) + ChartEditorDialogHandler.openUserGuideDialog(this); + } + + function handleToolboxes() + { + handleDifficultyToolbox(); + handlePlayerPreviewToolbox(); + handleOpponentPreviewToolbox(); + } + + function handleDifficultyToolbox() + { + if (difficultySelectDirty) + { + difficultySelectDirty = false; + + // Manage the Select Difficulty tree view. + var difficultyToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + if (difficultyToolbox == null) + return; + + var treeView:TreeView = difficultyToolbox.findComponent('difficultyToolboxTree'); + if (treeView == null) + return; + + // Clear the tree view so we can rebuild it. + treeView.clearNodes(); + + var treeSong = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName', icon: "haxeui-core/styles/default/haxeui_tiny.png"}); + treeSong.expanded = true; + + for (curVariation in availableVariations) + { + var variationMetadata:SongMetadata = songMetadata.get(curVariation); + + var treeVariation = treeSong.addNode({ + id: 'stv_variation_$curVariation', + text: 'V: ${curVariation.toTitleCase()}', + // icon: "haxeui-core/styles/default/haxeui_tiny.png" + }); + treeVariation.expanded = true; + + var difficultyList = variationMetadata.playData.difficulties; + + for (difficulty in difficultyList) + { + var treeDifficulty = treeVariation.addNode({ + id: 'stv_difficulty_${curVariation}_$difficulty', + text: 'D: ${difficulty.toTitleCase()}', + // icon: "haxeui-core/styles/default/haxeui_tiny.png" + }); + } + } + + treeView.onChange = onChangeTreeDifficulty; + treeView.selectedNode = getCurrentTreeDifficultyNode(); + } + } + + function handlePlayerPreviewToolbox() + { + // Manage the Select Difficulty tree view. + var charPreviewToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT); + if (charPreviewToolbox == null) + return; + + var charPlayer:CharacterPlayer = charPreviewToolbox.findComponent('charPlayer'); + if (charPlayer == null) + return; + + currentPlayerCharacterPlayer = charPlayer; + } + + function handleOpponentPreviewToolbox() + { + // Manage the Select Difficulty tree view. + var charPreviewToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT); + if (charPreviewToolbox == null) + return; + + var charPlayer:CharacterPlayer = charPreviewToolbox.findComponent('charPlayer'); + if (charPlayer == null) + return; + + currentOpponentCharacterPlayer = charPlayer; + } + + override function dispatchEvent(event:ScriptEvent) + { + super.dispatchEvent(event); + + // We can't use the ScriptedEventDispatcher with currentCharPlayer because we can't use the IScriptedClass interface on it. + if (currentPlayerCharacterPlayer != null) + { + switch (event.type) + { + case ScriptEvent.UPDATE: + currentPlayerCharacterPlayer.onUpdate(cast event); + case ScriptEvent.SONG_BEAT_HIT: + currentPlayerCharacterPlayer.onBeatHit(cast event); + case ScriptEvent.SONG_STEP_HIT: + currentPlayerCharacterPlayer.onStepHit(cast event); + case ScriptEvent.NOTE_HIT: + currentPlayerCharacterPlayer.onNoteHit(cast event); + } + } + + if (currentOpponentCharacterPlayer != null) + { + switch (event.type) + { + case ScriptEvent.UPDATE: + currentOpponentCharacterPlayer.onUpdate(cast event); + case ScriptEvent.SONG_BEAT_HIT: + currentOpponentCharacterPlayer.onBeatHit(cast event); + case ScriptEvent.SONG_STEP_HIT: + currentOpponentCharacterPlayer.onStepHit(cast event); + case ScriptEvent.NOTE_HIT: + currentOpponentCharacterPlayer.onNoteHit(cast event); + } + } + } + + function getCurrentTreeDifficultyNode():TreeViewNode + { + var treeView:TreeView = findComponent('difficultyToolboxTree'); + + if (treeView == null) + return null; + + var result = treeView.findNodeByPath('stv_song/stv_variation_$selectedVariation/stv_difficulty_${selectedVariation}_$selectedDifficulty', 'id'); + + if (result == null) + return null; + + return result; + } + + function onChangeTreeDifficulty(event:UIEvent):Void + { + // Get the newly selected node. + var treeView:TreeView = cast event.target; + var targetNode:TreeViewNode = treeView.selectedNode; + + if (targetNode == null) + { + trace('No target node!'); + // Reset the user's selection. + treeView.selectedNode = getCurrentTreeDifficultyNode(); + return; + } + + switch (targetNode.data.id.split('_')[1]) + { + case 'difficulty': + var variation = targetNode.data.id.split('_')[2]; + var difficulty = targetNode.data.id.split('_')[3]; + + if (variation != null && difficulty != null) + { + trace('Changing difficulty to $variation:$difficulty'); + selectedVariation = variation; + selectedDifficulty = difficulty; + } + // case 'song': + // case 'variation': + default: + // Reset the user's selection. + trace('Selected wrong node type, resetting selection.'); + treeView.selectedNode = getCurrentTreeDifficultyNode(); + } + } + + function addDifficulty(variation:String) {} + + function addVariation(variationId:String) + { + // Create a new variation with the specified ID. + songMetadata.set(variationId, currentSongMetadata.clone(variationId)); + // Switch to the new variation. + selectedVariation = variationId; + } + + /** + * Handle the player preview/gameplay test area on the left side. + */ + function handlePlayerDisplay() {} + + /** + * Handles the note preview/scroll area on the right side. + * Notes are rendered here as small bars. + * This function also handles: + * - Moving the viewport preview box around based on its current position. + * - Scrolling the note preview area down if the note preview is taller than the screen, + * and the viewport nears the end of the visible area. + */ + function handleNotePreview() + { + // + if (notePreviewDirty) + { + notePreviewDirty = false; + + var PREVIEW_WIDTH:Int = GRID_SIZE * 2; + var STEP_HEIGHT:Int = 1; + var PREVIEW_HEIGHT:Int = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * STEP_HEIGHT); + + notePreviewBitmap = new BitmapData(PREVIEW_WIDTH, PREVIEW_HEIGHT, true); + notePreviewBitmap.fillRect(new Rectangle(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT), PREVIEW_BG_COLOR); + } + } + + /** + * Perform a spot update on the note preview, by editing the note preview + * only where necessary. More efficient than a full update. + */ + function updateNotePreview(note:SongNoteData, ?deleteNote:Bool = false) {} + + /** + * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status. + * Does not handle onClick ACTIONS of the menubar. + */ + function handleMenubar() + { + if (commandHistoryDirty) + { + commandHistoryDirty = false; + + // Update the Undo and Redo buttons. + var undoButton:MenuItem = findComponent('menubarItemUndo', MenuItem); + + if (undoButton != null) + { + if (undoHistory.length == 0) + { + // Disable the Undo button. + undoButton.disabled = true; + undoButton.text = "Undo"; + } + else + { + // Change the label to the last command. + undoButton.disabled = false; + undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}'; + } + } + else + { + trace("undoButton is null"); + } + + var redoButton:MenuItem = findComponent('menubarItemRedo', MenuItem); + + if (redoButton != null) + { + if (redoHistory.length == 0) + { + // Disable the Redo button. + redoButton.disabled = true; + redoButton.text = "Redo"; + } + else + { + // Change the label to the last command. + redoButton.disabled = false; + redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}'; + } + } + else + { + trace("redoButton is null"); + } + } + } + + /** + * Handle syncronizing the conductor with the music playback. + */ + function handleMusicPlayback() + { + if (audioInstTrack != null && audioInstTrack.playing) + { + if (FlxG.mouse.pressedMiddle) + { + // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat! + + var oldStepTime = Conductor.currentStepTime; + var oldSongPosition = Conductor.songPosition; + Conductor.update(audioInstTrack.time); + handleHitsounds(oldSongPosition, Conductor.songPosition); + // Resync vocals. + if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + audioVocalTrackGroup.time = audioInstTrack.time; + var diffStepTime = Conductor.currentStepTime - oldStepTime; + + // Move the playhead. + playheadPositionInPixels += diffStepTime * GRID_SIZE; + + // We don't move the song to scroll position, or update the note sprites. + } + else + { + // Else, move the entire view. + var oldSongPosition = Conductor.songPosition; + Conductor.update(audioInstTrack.time); + handleHitsounds(oldSongPosition, Conductor.songPosition); + // Resync vocals. + if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + audioVocalTrackGroup.time = audioInstTrack.time; + + // We need time in fractional steps here to allow the song to actually play. + // Also account for a potentially offset playhead. + scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels; + + // DO NOT move song to scroll position here specifically. + + // We need to update the note sprites. + noteDisplayDirty = true; + } + } + + if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen) + { + toggleAudioPlayback(); + } + } + + /** + * Handle the playback of hitsounds. + */ + function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void + { + if (!hitsoundsEnabled) + return; + + // Assume notes are sorted by time. + for (noteData in currentSongChartNoteData) + { + if (noteData.time < oldSongPosition) + // Note is in the past. + continue; + + if (noteData.time >= newSongPosition) + // Note is in the future. + return; + + // Note was just hit. + + // 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; + tempNote.scrollFactor.set(0, 0); + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) + continue; + + // Hitsounds. + switch (noteData.getStrumlineIndex()) + { + case 0: // Player + if (hitsoundsEnabledPlayer) + playSound(Paths.sound('funnyNoise/funnyNoise-09')); + case 1: // Opponent + if (hitsoundsEnabledOpponent) + playSound(Paths.sound('funnyNoise/funnyNoise-010')); + } + } + } + + function startAudioPlayback() + { + if (audioInstTrack != null) + audioInstTrack.play(); + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.play(); + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.play(); + } + + function stopAudioPlayback() + { + if (audioInstTrack != null) + audioInstTrack.pause(); + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.pause(); + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.pause(); + } + + function toggleAudioPlayback() + { + if (audioInstTrack == null) + return; + + if (audioInstTrack.playing) + { + stopAudioPlayback(); + } + else + { + startAudioPlayback(); + } + } + + function handlePlayhead() + { + // Place notes at the playhead. + // TODO: Add the ability to switch modes. + if (true) + { + if (FlxG.keys.justPressed.ONE) + placeNoteAtPlayhead(0); + if (FlxG.keys.justPressed.TWO) + placeNoteAtPlayhead(1); + if (FlxG.keys.justPressed.THREE) + placeNoteAtPlayhead(2); + if (FlxG.keys.justPressed.FOUR) + placeNoteAtPlayhead(3); + if (FlxG.keys.justPressed.FIVE) + placeNoteAtPlayhead(4); + if (FlxG.keys.justPressed.SIX) + placeNoteAtPlayhead(5); + if (FlxG.keys.justPressed.SEVEN) + placeNoteAtPlayhead(6); + if (FlxG.keys.justPressed.EIGHT) + placeNoteAtPlayhead(7); + } + } + + function placeNoteAtPlayhead(column:Int):Void + { + var gridSnappedPlayheadPos = scrollPositionInPixels - (scrollPositionInPixels % GRID_SIZE); + } + + function set_scrollPositionInPixels(value:Float):Float + { + if (value < 0) + { + // If we're scrolling up, and we hit the top, + // but the playhead is in the middle, move the playhead up. + if (playheadPositionInPixels > 0) + { + var amount = scrollPositionInPixels - value; + playheadPositionInPixels -= amount; + } + + value = 0; + } + + if (value > songLengthInPixels) + value = songLengthInPixels; + + if (value == scrollPositionInPixels) + return value; + + this.scrollPositionInPixels = value; + + // Move the grid sprite to the correct position. + if (isViewDownscroll) + { + gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + } + else + { + gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + } + // Move the rendered notes to the correct position. + renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); + renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y); + renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y); + if (gridSpectrogram != null) + { + // Move the spectrogram to the correct position. + gridSpectrogram.y = gridTiledSprite.y; + gridSpectrogram.setPosition(0, 0); + } + + return this.scrollPositionInPixels; + } + + function get_playheadPositionInPixels():Float + { + return this.playheadPositionInPixels; + } + + function set_playheadPositionInPixels(value:Float):Float + { + // Make sure playhead doesn't go outside the song. + if (value + scrollPositionInPixels < 0) + value = -scrollPositionInPixels; + if (value + scrollPositionInPixels > songLengthInPixels) + value = songLengthInPixels - scrollPositionInPixels; + + this.playheadPositionInPixels = value; + + // Move the playhead sprite to the correct position. + gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + + return this.playheadPositionInPixels; + } + + /** + * Loads an instrumental from an absolute file path, replacing the current instrumental. + */ + public function loadInstrumentalFromPath(path:String):Void + { + #if sys + var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path); + loadInstrumentalFromBytes(fileBytes); + #else + trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); + #end + } + + /** + * Loads an instrumental from audio byte data, replacing the current instrumental. + */ + public function loadInstrumentalFromBytes(bytes:haxe.io.Bytes):Void + { + var openflSound = new openfl.media.Sound(); + openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); + audioInstTrack = FlxG.sound.load(openflSound, 1.0, false); + audioInstTrack.autoDestroy = false; + audioInstTrack.pause(); + + // Tell the user the load was successful. + // TODO: Un-bork this. + // showNotification('Loaded instrumental track successfully.'); + + postLoadInstrumental(); + } + + public function loadInstrumentalFromAsset(path:String):Void + { + var instTrack = FlxG.sound.load(path, 1.0, false); + audioInstTrack = instTrack; + + postLoadInstrumental(); + } + + function postLoadInstrumental() + { + // Prevent the time from skipping back to 0 when the song ends. + audioInstTrack.onComplete = function() + { + if (audioInstTrack != null) + audioInstTrack.pause(); + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.pause(); + }; + + songLengthInMs = audioInstTrack.length; + + gridTiledSprite.height = songLengthInPixels; + if (gridSpectrogram != null) + { + gridSpectrogram.setSound(audioInstTrack); + gridSpectrogram.generateSection(0, songLengthInMs / 1000); + } + + scrollPositionInPixels = 0; + playheadPositionInPixels = 0; + moveSongToScrollPosition(); + } + + /** + * Loads a vocal track from an absolute file path. + */ + public function loadVocalsFromPath(path:String):Void + { + #if sys + var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path); + loadVocalsFromBytes(fileBytes); + #else + trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); + #end + } + + public function loadVocalsFromAsset(path:String):Void + { + var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false); + audioVocalTrackGroup.add(vocalTrack); + } + + /** + * Loads a vocal track from audio byte data. + */ + public function loadVocalsFromBytes(bytes:haxe.io.Bytes):Void + { + var openflSound = new openfl.media.Sound(); + openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); + var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false); + audioVocalTrackGroup.add(vocalTrack); + + // Tell the user the load was successful. + // TODO: Un-bork this. + // showNotification('Loaded instrumental track successfully.'); + } + + /** + * Fetch's a song's existing chart and audio and loads it, replacing the current song. + */ + public function loadSongAsTemplate(songId:String) + { + var song:Song = SongDataParser.fetchSong(songId); + + if (song == null) + { + // showNotification('Failed to load song template.'); + return; + } + + // Load the song metadata. + var rawSongMetadata:Array = song.getRawMetadata(); + + this.songMetadata = new Map(); + + for (metadata in rawSongMetadata) + { + var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation; + this.songMetadata.set(variation, metadata); + } + + this.songChartData = new Map(); + + for (metadata in rawSongMetadata) + { + var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation; + this.songChartData.set(variation, SongDataParser.parseSongChartData(songId, metadata.variation)); + } + + Conductor.forceBPM(null); // Disable the forced BPM. + Conductor.mapTimeChanges(currentSongMetadata.timeChanges); + + loadInstrumentalFromAsset(Paths.inst(songId)); + loadVocalsFromAsset(Paths.voices(songId)); + + // showNotification('Loaded song ${songId}.'); + } + + /** + * When setting the scroll position, except when automatically scrolling during song playback, + * we need to update the conductor's current step time and the timestamp of the audio tracks. + */ + function moveSongToScrollPosition() + { + // Update the songPosition in the Conductor. + Conductor.update(scrollPositionInMs); + + // Update the songPosition in the audio tracks. + if (audioInstTrack != null) + audioInstTrack.time = scrollPositionInMs + playheadPositionInMs; + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs; + + // We need to update the note sprites because we changed the scroll position. + noteDisplayDirty = true; + } + + /** + * Perform (or redo) a command, then add it to the undo stack. + * + * @param command The command to perform. + * @param purgeRedoStack If true, the redo stack will be cleared. + */ + function performCommand(command:ChartEditorCommand, ?purgeRedoStack:Bool = true):Void + { + command.execute(this); + undoHistory.push(command); + commandHistoryDirty = true; + if (purgeRedoStack) + redoHistory = []; + } + + /** + * Undo a command, then add it to the redo stack. + * @param command The command to undo. + */ + function undoCommand(command:ChartEditorCommand):Void + { + command.undo(this); + redoHistory.push(command); + commandHistoryDirty = true; + } + + /** + * Undo the last command in the undo stack, then add it to the redo stack. + */ + function undoLastCommand():Void + { + if (undoHistory.length == 0) + { + trace('No actions to undo.'); + return; + } + + var command = undoHistory.pop(); + undoCommand(command); + } + + /** + * Redo the last command in the redo stack, then add it to the undo stack. + */ + function redoLastCommand():Void + { + if (redoHistory.length == 0) + { + trace('No actions to redo.'); + return; + } + + var command = redoHistory.pop(); + performCommand(command, false); + } + + function sortChartData() + { + currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int + { + return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); + }); + + currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int + { + return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); + }); + } + + function playMetronomeTick(?high:Bool = false) + { + playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}')); + } + + function isNoteSelected(note:SongNoteData):Bool + { + return currentNoteSelection.indexOf(note) != -1; + } + + function isEventSelected(event:SongEventData):Bool + { + return currentEventSelection.indexOf(event) != -1; + } + + /** + * Play a sound effect. + * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance. + */ + function playSound(path:String) + { + var snd:FlxSound = FlxG.sound.list.recycle(FlxSound); + snd.loadEmbedded(FlxG.sound.cache(path)); + snd.autoDestroy = true; + FlxG.sound.list.add(snd); + snd.play(); + } + + override function destroy() + { + super.destroy(); + + cleanupAutoSave(); + + @:privateAccess + ChartEditorNoteSprite.noteFrameCollection = null; + } + + /** + * Displays a notification to the user. The only action is to dismiss. + */ + function showNotification(text:String) + { + // Make it appear. + notifBar.show(); + + // Auto dismiss. + new FlxTimer().start(NOTIFICATION_DISMISS_TIME, (_:FlxTimer) -> dismissNotification()); + } + + /** + * Dismiss any existing notifications, if there are any. + */ + function dismissNotification():Void + { + notifBar.hide(); + } + + /** + * @param force Whether to force the export without prompting the user for a file location. + * @param tmp If true, save to the temporary directory instead of the local `backup` directory. + */ + public function exportAllSongData(?force:Bool = false, ?tmp:Bool = false):Void + { + var zipEntries = []; + + for (variation in availableVariations) + { + var variationId = variation; + if (variation == '' || variation == 'default' || variation == 'normal') + { + variationId = ''; + } + + if (variationId == '') + { + var variationMetadata = songMetadata.get(variation); + zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata.json', SerializerUtil.toJSON(variationMetadata))); + var variationChart = songChartData.get(variation); + zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart.json', SerializerUtil.toJSON(variationChart))); + } + else + { + var variationMetadata = songMetadata.get(variation); + zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata-$variationId.json', SerializerUtil.toJSON(variationMetadata))); + var variationChart = songChartData.get(variation); + zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart-$variationId.json', SerializerUtil.toJSON(variationChart))); + } + } + + // TODO: Add audio files to the ZIP. + + trace('Exporting ${zipEntries.length} files to ZIP...'); + + if (force) + { + var targetPath:String = tmp ? Path.join([FileUtil.getTempDir(), 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']) : Path.join(['./backups/', 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']); + + // We have to force write because the program will die before the save dialog is closed. + trace('Force exporting to $targetPath...'); + FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath); + return; + } + + // Prompt and save. + var onSave:Array->Void = (paths:Array) -> + { + trace('Successfully exported files.'); + }; + + var onCancel:Void->Void = () -> + { + trace('Export cancelled.'); + }; + + FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '$currentSongId-chart.zip'); + } } diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx index ab45e2e90..88bb9eca4 100644 --- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx @@ -1,17 +1,25 @@ package funkin.ui.debug.charting; -import funkin.play.song.SongData.SongTimeChange; -import haxe.ui.components.Slider; -import haxe.ui.components.NumberStepper; -import haxe.ui.components.NumberStepper; -import haxe.ui.components.TextField; +import haxe.ui.data.ArrayDataSource; import funkin.play.character.BaseCharacter.CharacterType; -import funkin.ui.haxeui.components.CharacterPlayer; +import funkin.play.event.SongEvent; +import funkin.play.song.SongData.SongTimeChange; import funkin.play.song.SongSerializer; +import funkin.ui.haxeui.components.CharacterPlayer; import haxe.ui.components.Button; +import haxe.ui.components.CheckBox; import haxe.ui.components.DropDown; -import haxe.ui.containers.Group; +import haxe.ui.components.Label; +import haxe.ui.components.NumberStepper; +import haxe.ui.components.NumberStepper; +import haxe.ui.components.Slider; +import haxe.ui.components.TextField; import haxe.ui.containers.dialogs.Dialog; +import haxe.ui.containers.Box; +import haxe.ui.containers.Frame; +import haxe.ui.containers.Grid; +import haxe.ui.containers.Group; +import haxe.ui.core.Component; import haxe.ui.events.UIEvent; /** @@ -19,415 +27,556 @@ import haxe.ui.events.UIEvent; */ enum ChartEditorToolMode { - Select; - Place; + Select; + Place; } class ChartEditorToolboxHandler { - public static function setToolboxState(state:ChartEditorState, id:String, shown:Bool):Void - { - if (shown) - showToolbox(state, id); - else - hideToolbox(state, id); - } - - public static function showToolbox(state:ChartEditorState, id:String) - { - var toolbox:Dialog = state.activeToolboxes.get(id); - - if (toolbox == null) - toolbox = initToolbox(state, id); - - if (toolbox != null) - { - toolbox.showDialog(false); - } - else - { - trace('ChartEditorToolboxHandler.showToolbox() - Could not retrieve toolbox: $id'); - } - } - - public static function hideToolbox(state:ChartEditorState, id:String):Void - { - var toolbox:Dialog = state.activeToolboxes.get(id); - - if (toolbox == null) - toolbox = initToolbox(state, id); - - if (toolbox != null) - { - toolbox.hideDialog(DialogButton.CANCEL); - } - else - { - trace('ChartEditorToolboxHandler.hideToolbox() - Could not retrieve toolbox: $id'); - } - } - - public static function minimizeToolbox(state:ChartEditorState, id:String):Void - { - } - - public static function maximizeToolbox(state:ChartEditorState, id:String):Void - { - } - - public static function initToolbox(state:ChartEditorState, id:String):Dialog - { - var toolbox:Dialog = null; - switch (id) - { - case ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT: - toolbox = buildToolboxToolsLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT: - toolbox = buildToolboxNoteDataLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT: - toolbox = buildToolboxEventDataLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT: - toolbox = buildToolboxDifficultyLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: - toolbox = buildToolboxMetadataLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT: - toolbox = buildToolboxCharactersLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: - toolbox = buildToolboxPlayerPreviewLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: - toolbox = buildToolboxOpponentPreviewLayout(state); - default: - trace('ChartEditorToolboxHandler.initToolbox() - Unknown toolbox ID: $id'); - toolbox = null; - } - - // Make sure we can reuse the toolbox later. - toolbox.destroyOnClose = false; - state.activeToolboxes.set(id, toolbox); - - return toolbox; - } - - public static function getToolbox(state:ChartEditorState, id:String):Dialog - { - var toolbox:Dialog = state.activeToolboxes.get(id); - - // Initialize the toolbox without showing it. - if (toolbox == null) - toolbox = initToolbox(state, id); - - return toolbox; - } - - static function buildToolboxToolsLayout(state:ChartEditorState):Dialog - { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 50; - toolbox.y = 50; - - toolbox.onDialogClosed = (event:DialogEvent) -> - { - state.setUICheckboxSelected('menubarItemToggleToolboxTools', false); - } - - var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group); - - if (toolsGroup == null) return null; - - toolsGroup.onChange = (event:UIEvent) -> - { - switch (event.target.id) - { - case 'toolboxToolsGroupSelect': - state.currentToolMode = ChartEditorToolMode.Select; - case 'toolboxToolsGroupPlace': - state.currentToolMode = ChartEditorToolMode.Place; - default: - trace('ChartEditorToolboxHandler.buildToolboxToolsLayout() - Unknown toolbox tool selected: $event.target.id'); - } - } - - return toolbox; - } - - static function buildToolboxNoteDataLayout(state:ChartEditorState):Dialog - { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 75; - toolbox.y = 100; - - toolbox.onDialogClosed = (event:DialogEvent) -> - { - state.setUICheckboxSelected('menubarItemToggleToolboxNotes', false); - } - - var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown); - - toolboxNotesNoteKind.onChange = (event:UIEvent) -> - { - state.selectedNoteKind = event.data.id; - } - - return toolbox; - } - - static function buildToolboxEventDataLayout(state:ChartEditorState):Dialog - { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 100; - toolbox.y = 150; - - toolbox.onDialogClosed = (event:DialogEvent) -> - { - state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false); - } - - return toolbox; - } - - static function buildToolboxDifficultyLayout(state:ChartEditorState):Dialog - { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 125; - toolbox.y = 200; - - toolbox.onDialogClosed = (event:DialogEvent) -> - { - state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false); - } - - var difficultyToolboxSaveMetadata:Button = toolbox.findComponent("difficultyToolboxSaveMetadata", Button); - var difficultyToolboxSaveChart:Button = toolbox.findComponent("difficultyToolboxSaveChart", Button); - var difficultyToolboxSaveAll:Button = toolbox.findComponent("difficultyToolboxSaveAll", Button); - var difficultyToolboxLoadMetadata:Button = toolbox.findComponent("difficultyToolboxLoadMetadata", Button); - var difficultyToolboxLoadChart:Button = toolbox.findComponent("difficultyToolboxLoadChart", Button); - - difficultyToolboxSaveMetadata.onClick = (event:UIEvent) -> - { - SongSerializer.exportSongMetadata(state.currentSongMetadata); - }; - - difficultyToolboxSaveChart.onClick = (event:UIEvent) -> - { - SongSerializer.exportSongChartData(state.currentSongChartData); - }; - - difficultyToolboxSaveAll.onClick = (event:UIEvent) -> - { - state.exportAllSongData(); - }; - - difficultyToolboxLoadMetadata.onClick = (event:UIEvent) -> - { - // Replace metadata for current variation. - SongSerializer.importSongMetadataAsync(function(songMetadata) - { - state.currentSongMetadata = songMetadata; - }); - }; - - difficultyToolboxLoadChart.onClick = (event:UIEvent) -> - { - // Replace chart data for current variation. - SongSerializer.importSongChartDataAsync(function(songChartData) - { - state.currentSongChartData = songChartData; - state.noteDisplayDirty = true; - }); - }; - - state.difficultySelectDirty = true; - - return toolbox; - } - - static function buildToolboxMetadataLayout(state:ChartEditorState):Dialog - { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 150; - toolbox.y = 250; - - toolbox.onDialogClosed = (event:DialogEvent) -> - { - state.setUICheckboxSelected('menubarItemToggleToolboxMetadata', false); - } - - var inputSongName:TextField = toolbox.findComponent('inputSongName', TextField); - inputSongName.onChange = (event:UIEvent) -> - { - var valid = event.target.text != null && event.target.text != ""; - - if (valid) - { - inputSongName.removeClass('invalid-value'); - state.currentSongMetadata.songName = event.target.text; - } - else - { - state.currentSongMetadata.songName = null; - } - }; - - var inputSongArtist:TextField = toolbox.findComponent('inputSongArtist', TextField); - inputSongArtist.onChange = (event:UIEvent) -> - { - var valid = event.target.text != null && event.target.text != ""; - - if (valid) - { - inputSongArtist.removeClass('invalid-value'); - state.currentSongMetadata.artist = event.target.text; - } - else - { - state.currentSongMetadata.artist = null; - } - }; - - var inputStage:DropDown = toolbox.findComponent('inputStage', DropDown); - inputStage.onChange = (event:UIEvent) -> - { - var valid = event.data != null && event.data.id != null; - - if (valid) { - state.currentSongMetadata.playData.stage = event.data.id; - } - }; - - var inputNoteSkin:DropDown = toolbox.findComponent('inputNoteSkin', DropDown); - inputNoteSkin.onChange = (event:UIEvent) -> - { - if (event.data.id == null) - return; - state.currentSongMetadata.playData.noteSkin = event.data.id; - }; - - var inputBPM:NumberStepper = toolbox.findComponent('inputBPM', NumberStepper); - inputBPM.onChange = (event:UIEvent) -> - { - if (event.value == null || event.value <= 0) - return; - - var timeChanges = state.currentSongMetadata.timeChanges; - if (timeChanges == null || timeChanges.length == 0) - { - timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])]; - } - else - { - timeChanges[0].bpm = event.value; - } - - Conductor.forceBPM(event.value); - - state.currentSongMetadata.timeChanges = timeChanges; - }; - - var inputScrollSpeed:Slider = toolbox.findComponent('inputScrollSpeed', Slider); - inputScrollSpeed.onChange = (event:UIEvent) -> - { - var valid = event.target.value != null && event.target.value > 0; - - if (valid) - { - inputScrollSpeed.removeClass('invalid-value'); - state.currentSongChartData.scrollSpeed = event.target.value; - } - else - { - state.currentSongChartData.scrollSpeed = null; - } - }; - - - return toolbox; - } - - static function buildToolboxCharactersLayout(state:ChartEditorState):Dialog - { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 175; - toolbox.y = 300; - - toolbox.onDialogClosed = (event:DialogEvent) -> - { - state.setUICheckboxSelected('menubarItemToggleToolboxCharacters', false); - } - - return toolbox; - } - - static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Dialog - { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 200; - toolbox.y = 350; - - toolbox.onDialogClosed = (event:DialogEvent) -> - { - state.setUICheckboxSelected('menubarItemToggleToolboxPlayerPreview', false); - } - - var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer'); - // TODO: We need to implement character swapping in ChartEditorState. - charPlayer.loadCharacter('bf'); - //charPlayer.setScale(0.5); - charPlayer.setCharacterType(CharacterType.BF); - charPlayer.flip = true; - - return toolbox; - } - - static function buildToolboxOpponentPreviewLayout(state:ChartEditorState):Dialog - { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 200; - toolbox.y = 350; - - toolbox.onDialogClosed = (event:DialogEvent) -> - { - state.setUICheckboxSelected('menubarItemToggleToolboxOpponentPreview', false); - } - - var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer'); - // TODO: We need to implement character swapping in ChartEditorState. - charPlayer.loadCharacter('dad'); - // charPlayer.setScale(0.5); - charPlayer.setCharacterType(CharacterType.DAD); - charPlayer.flip = false; - - return toolbox; - } + public static function setToolboxState(state:ChartEditorState, id:String, shown:Bool):Void + { + if (shown) + showToolbox(state, id); + else + hideToolbox(state, id); + } + + public static function showToolbox(state:ChartEditorState, id:String) + { + var toolbox:Dialog = state.activeToolboxes.get(id); + + if (toolbox == null) + toolbox = initToolbox(state, id); + + if (toolbox != null) + { + toolbox.showDialog(false); + } + else + { + trace('ChartEditorToolboxHandler.showToolbox() - Could not retrieve toolbox: $id'); + } + } + + public static function hideToolbox(state:ChartEditorState, id:String):Void + { + var toolbox:Dialog = state.activeToolboxes.get(id); + + if (toolbox == null) + toolbox = initToolbox(state, id); + + if (toolbox != null) + { + toolbox.hideDialog(DialogButton.CANCEL); + } + else + { + trace('ChartEditorToolboxHandler.hideToolbox() - Could not retrieve toolbox: $id'); + } + } + + public static function minimizeToolbox(state:ChartEditorState, id:String):Void {} + + public static function maximizeToolbox(state:ChartEditorState, id:String):Void {} + + public static function initToolbox(state:ChartEditorState, id:String):Dialog + { + var toolbox:Dialog = null; + switch (id) + { + case ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT: + toolbox = buildToolboxToolsLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT: + toolbox = buildToolboxNoteDataLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT: + toolbox = buildToolboxEventDataLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT: + toolbox = buildToolboxDifficultyLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: + toolbox = buildToolboxMetadataLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT: + toolbox = buildToolboxCharactersLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: + toolbox = buildToolboxPlayerPreviewLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: + toolbox = buildToolboxOpponentPreviewLayout(state); + default: + // This happens if you try to load an unknown layout. + trace('ChartEditorToolboxHandler.initToolbox() - Unknown toolbox ID: $id'); + toolbox = null; + } + + // This happens if the layout you try to load has a syntax error. + if (toolbox == null) + return null; + + // Make sure we can reuse the toolbox later. + toolbox.destroyOnClose = false; + state.activeToolboxes.set(id, toolbox); + + return toolbox; + } + + public static function getToolbox(state:ChartEditorState, id:String):Dialog + { + var toolbox:Dialog = state.activeToolboxes.get(id); + + // Initialize the toolbox without showing it. + if (toolbox == null) + toolbox = initToolbox(state, id); + + return toolbox; + } + + static function buildToolboxToolsLayout(state:ChartEditorState):Dialog + { + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT); + + if (toolbox == null) + return null; + + // Starting position. + toolbox.x = 50; + toolbox.y = 50; + + toolbox.onDialogClosed = (event:DialogEvent) -> + { + state.setUICheckboxSelected('menubarItemToggleToolboxTools', false); + } + + var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group); + + if (toolsGroup == null) + return null; + + toolsGroup.onChange = (event:UIEvent) -> + { + switch (event.target.id) + { + case 'toolboxToolsGroupSelect': + state.currentToolMode = ChartEditorToolMode.Select; + case 'toolboxToolsGroupPlace': + state.currentToolMode = ChartEditorToolMode.Place; + default: + trace('ChartEditorToolboxHandler.buildToolboxToolsLayout() - Unknown toolbox tool selected: $event.target.id'); + } + } + + return toolbox; + } + + static function buildToolboxNoteDataLayout(state:ChartEditorState):Dialog + { + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT); + + if (toolbox == null) + return null; + + // Starting position. + toolbox.x = 75; + toolbox.y = 100; + + toolbox.onDialogClosed = (event:DialogEvent) -> + { + state.setUICheckboxSelected('menubarItemToggleToolboxNotes', false); + } + + var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown); + var toolboxNotesCustomKindLabel:Label = toolbox.findComponent("toolboxNotesCustomKindLabel", Label); + var toolboxNotesCustomKind:TextField = toolbox.findComponent("toolboxNotesCustomKind", TextField); + + toolboxNotesNoteKind.onChange = (event:UIEvent) -> + { + var isCustom = (event.data.id == '~CUSTOM~'); + + if (isCustom) + { + toolboxNotesCustomKindLabel.hidden = false; + toolboxNotesCustomKind.hidden = false; + + state.selectedNoteKind = toolboxNotesCustomKind.text; + } + else + { + toolboxNotesCustomKindLabel.hidden = true; + toolboxNotesCustomKind.hidden = true; + + state.selectedNoteKind = event.data.id; + } + } + + toolboxNotesCustomKind.onChange = (event:UIEvent) -> + { + state.selectedNoteKind = toolboxNotesCustomKind.text; + } + + return toolbox; + } + + static function buildToolboxEventDataLayout(state:ChartEditorState):Dialog + { + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT); + + if (toolbox == null) + return null; + + // Starting position. + toolbox.x = 100; + toolbox.y = 150; + + toolbox.onDialogClosed = (event:DialogEvent) -> + { + state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false); + } + + var toolboxEventsEventKind:DropDown = toolbox.findComponent("toolboxEventsEventKind", DropDown); + var toolboxEventsDataGrid:Grid = toolbox.findComponent("toolboxEventsDataGrid", Grid); + + toolboxEventsEventKind.dataSource = new ArrayDataSource(); + + var songEvents:Array = SongEventParser.listEvents(); + + for (event in songEvents) + { + toolboxEventsEventKind.dataSource.add({text: event.getTitle(), value: event.id}); + } + + toolboxEventsEventKind.onChange = (event:UIEvent) -> + { + var eventType:String = event.data.value; + + trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType'); + + var schema:SongEventSchema = SongEventParser.getEventSchema(eventType); + + if (schema == null) + { + trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Unknown event kind: $eventType'); + return; + } + + buildEventDataFormFromSchema(state, toolboxEventsDataGrid, schema); + } + + return toolbox; + } + + static function buildEventDataFormFromSchema(state:ChartEditorState, target:Box, schema:SongEventSchema):Void + { + trace(schema); + // Clear the frame. + target.removeAllComponents(); + + state.selectedEventData = {}; + + for (field in schema) + { + // Add a label. + var label:Label = new Label(); + label.text = field.title; + target.addComponent(label); + + var input:Component; + switch (field.type) + { + case INTEGER: + var numberStepper:NumberStepper = new NumberStepper(); + numberStepper.id = field.name; + numberStepper.step = field.step == null ? 1.0 : field.step; + numberStepper.min = field.min; + numberStepper.max = field.max; + numberStepper.value = field.defaultValue; + input = numberStepper; + case FLOAT: + var numberStepper:NumberStepper = new NumberStepper(); + numberStepper.id = field.name; + numberStepper.step = field.step == null ? 0.1 : field.step; + numberStepper.min = field.min; + numberStepper.max = field.max; + numberStepper.value = field.defaultValue; + input = numberStepper; + case BOOL: + var checkBox = new CheckBox(); + checkBox.id = field.name; + checkBox.selected = field.defaultValue == true; + input = checkBox; + case ENUM: + var dropDown:DropDown = new DropDown(); + dropDown.id = field.name; + dropDown.dataSource = new ArrayDataSource(); + + // Add entries to the dropdown. + for (optionName in field.keys.keys()) + { + var optionValue = field.keys.get(optionName); + trace('$optionName : $optionValue'); + dropDown.dataSource.add({value: optionValue, text: optionName}); + } + + dropDown.value = field.defaultValue; + + input = dropDown; + case STRING: + input = new TextField(); + input.id = field.name; + input.text = field.defaultValue; + default: + // Unknown type. Display a label so we know what it is. + input = new Label(); + input.id = field.name; + input.text = field.type; + } + + target.addComponent(input); + + input.onChange = (event:UIEvent) -> + { + trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${event.target.value}'); + + if (event.target.value == null) + state.selectedEventData.remove(event.target.id); + else + state.selectedEventData.set(event.target.id, event.target.value); + } + } + } + + static function buildToolboxDifficultyLayout(state:ChartEditorState):Dialog + { + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + + if (toolbox == null) + return null; + + // Starting position. + toolbox.x = 125; + toolbox.y = 200; + + toolbox.onDialogClosed = (event:DialogEvent) -> + { + state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false); + } + + var difficultyToolboxSaveMetadata:Button = toolbox.findComponent("difficultyToolboxSaveMetadata", Button); + var difficultyToolboxSaveChart:Button = toolbox.findComponent("difficultyToolboxSaveChart", Button); + var difficultyToolboxSaveAll:Button = toolbox.findComponent("difficultyToolboxSaveAll", Button); + var difficultyToolboxLoadMetadata:Button = toolbox.findComponent("difficultyToolboxLoadMetadata", Button); + var difficultyToolboxLoadChart:Button = toolbox.findComponent("difficultyToolboxLoadChart", Button); + + difficultyToolboxSaveMetadata.onClick = (event:UIEvent) -> + { + SongSerializer.exportSongMetadata(state.currentSongMetadata); + }; + + difficultyToolboxSaveChart.onClick = (event:UIEvent) -> + { + SongSerializer.exportSongChartData(state.currentSongChartData); + }; + + difficultyToolboxSaveAll.onClick = (event:UIEvent) -> + { + state.exportAllSongData(); + }; + + difficultyToolboxLoadMetadata.onClick = (event:UIEvent) -> + { + // Replace metadata for current variation. + SongSerializer.importSongMetadataAsync(function(songMetadata) + { + state.currentSongMetadata = songMetadata; + }); + }; + + difficultyToolboxLoadChart.onClick = (event:UIEvent) -> + { + // Replace chart data for current variation. + SongSerializer.importSongChartDataAsync(function(songChartData) + { + state.currentSongChartData = songChartData; + state.noteDisplayDirty = true; + }); + }; + + state.difficultySelectDirty = true; + + return toolbox; + } + + static function buildToolboxMetadataLayout(state:ChartEditorState):Dialog + { + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + + if (toolbox == null) + return null; + + // Starting position. + toolbox.x = 150; + toolbox.y = 250; + + toolbox.onDialogClosed = (event:DialogEvent) -> + { + state.setUICheckboxSelected('menubarItemToggleToolboxMetadata', false); + } + + var inputSongName:TextField = toolbox.findComponent('inputSongName', TextField); + inputSongName.onChange = (event:UIEvent) -> + { + var valid = event.target.text != null && event.target.text != ""; + + if (valid) + { + inputSongName.removeClass('invalid-value'); + state.currentSongMetadata.songName = event.target.text; + } + else + { + state.currentSongMetadata.songName = null; + } + }; + + var inputSongArtist:TextField = toolbox.findComponent('inputSongArtist', TextField); + inputSongArtist.onChange = (event:UIEvent) -> + { + var valid = event.target.text != null && event.target.text != ""; + + if (valid) + { + inputSongArtist.removeClass('invalid-value'); + state.currentSongMetadata.artist = event.target.text; + } + else + { + state.currentSongMetadata.artist = null; + } + }; + + var inputStage:DropDown = toolbox.findComponent('inputStage', DropDown); + inputStage.onChange = (event:UIEvent) -> + { + var valid = event.data != null && event.data.id != null; + + if (valid) + { + state.currentSongMetadata.playData.stage = event.data.id; + } + }; + + var inputNoteSkin:DropDown = toolbox.findComponent('inputNoteSkin', DropDown); + inputNoteSkin.onChange = (event:UIEvent) -> + { + if (event.data.id == null) + return; + state.currentSongMetadata.playData.noteSkin = event.data.id; + }; + + var inputBPM:NumberStepper = toolbox.findComponent('inputBPM', NumberStepper); + inputBPM.onChange = (event:UIEvent) -> + { + if (event.value == null || event.value <= 0) + return; + + var timeChanges = state.currentSongMetadata.timeChanges; + if (timeChanges == null || timeChanges.length == 0) + { + timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])]; + } + else + { + timeChanges[0].bpm = event.value; + } + + Conductor.forceBPM(event.value); + + state.currentSongMetadata.timeChanges = timeChanges; + }; + + var inputScrollSpeed:Slider = toolbox.findComponent('inputScrollSpeed', Slider); + inputScrollSpeed.onChange = (event:UIEvent) -> + { + var valid = event.target.value != null && event.target.value > 0; + + if (valid) + { + inputScrollSpeed.removeClass('invalid-value'); + state.currentSongChartData.scrollSpeed = event.target.value; + } + else + { + state.currentSongChartData.scrollSpeed = null; + } + }; + + return toolbox; + } + + static function buildToolboxCharactersLayout(state:ChartEditorState):Dialog + { + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT); + + if (toolbox == null) + return null; + + // Starting position. + toolbox.x = 175; + toolbox.y = 300; + + toolbox.onDialogClosed = (event:DialogEvent) -> + { + state.setUICheckboxSelected('menubarItemToggleToolboxCharacters', false); + } + + return toolbox; + } + + static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Dialog + { + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT); + + if (toolbox == null) + return null; + + // Starting position. + toolbox.x = 200; + toolbox.y = 350; + + toolbox.onDialogClosed = (event:DialogEvent) -> + { + state.setUICheckboxSelected('menubarItemToggleToolboxPlayerPreview', false); + } + + var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer'); + // TODO: We need to implement character swapping in ChartEditorState. + charPlayer.loadCharacter('bf'); + // charPlayer.setScale(0.5); + charPlayer.setCharacterType(CharacterType.BF); + charPlayer.flip = true; + + return toolbox; + } + + static function buildToolboxOpponentPreviewLayout(state:ChartEditorState):Dialog + { + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT); + + if (toolbox == null) + return null; + + // Starting position. + toolbox.x = 200; + toolbox.y = 350; + + toolbox.onDialogClosed = (event:DialogEvent) -> + { + state.setUICheckboxSelected('menubarItemToggleToolboxOpponentPreview', false); + } + + var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer'); + // TODO: We need to implement character swapping in ChartEditorState. + charPlayer.loadCharacter('dad'); + // charPlayer.setScale(0.5); + charPlayer.setCharacterType(CharacterType.DAD); + charPlayer.flip = false; + + return toolbox; + } } diff --git a/source/funkin/ui/haxeui/components/Notifbar.hx b/source/funkin/ui/haxeui/components/Notifbar.hx new file mode 100644 index 000000000..b643d360b --- /dev/null +++ b/source/funkin/ui/haxeui/components/Notifbar.hx @@ -0,0 +1,113 @@ +package funkin.ui.haxeui.components; + +import flixel.FlxG; +import flixel.util.FlxTimer; +import haxe.ui.RuntimeComponentBuilder; +import haxe.ui.components.Button; +import haxe.ui.components.Label; +import haxe.ui.containers.Box; +import haxe.ui.containers.SideBar; +import haxe.ui.containers.VBox; +import haxe.ui.core.Component; + +class Notifbar extends SideBar +{ + final NOTIFICATION_DISMISS_TIME = 5.0; // seconds + var dismissTimer:FlxTimer = null; + + var outerContainer:Box = null; + var container:VBox = null; + var message:Label = null; + var action:Button = null; + var dismiss:Button = null; + + public function new() + { + super(); + + buildSidebar(); + buildChildren(); + } + + public function showNotification(message:String, ?actionText:String = null, ?actionCallback:Void->Void = null, ?dismissTime:Float = null) + { + if (dismissTimer != null) + dismissNotification(); + + if (dismissTime == null) + dismissTime = NOTIFICATION_DISMISS_TIME; + + // Message text. + this.message.text = message; + + // Action + if (actionText != null) + { + this.action.text = actionText; + this.action.visible = true; + this.action.disabled = false; + this.action.onClick = (_) -> + { + actionCallback(); + }; + } + else + { + this.action.visible = false; + this.action.disabled = false; + this.action.onClick = null; + } + + this.show(); + + // Auto dismiss. + dismissTimer = new FlxTimer().start(dismissTime, (_:FlxTimer) -> dismissNotification()); + } + + public function dismissNotification() + { + if (dismissTimer != null) + { + dismissTimer.cancel(); + dismissTimer = null; + } + + this.hide(); + } + + function buildSidebar():Void + { + this.width = 256; + this.height = 80; + + // border-top: 1px solid #000; border-left: 1px solid #000; + this.styleString = "border: 1px solid #000; background-color: #3d3f41; padding: 8px; border-top-left-radius: 8px;"; + + // float to the right + this.x = FlxG.width - this.width; + + this.position = "bottom"; + this.method = "float"; + } + + function buildChildren():Void + { + outerContainer = cast(buildComponent("assets/data/notifbar.xml"), Box); + addComponent(outerContainer); + + container = outerContainer.findComponent('notifbarContainer', VBox); + message = outerContainer.findComponent('notifbarMessage', Label); + action = outerContainer.findComponent('notifbarAction', Button); + dismiss = outerContainer.findComponent('notifbarDismiss', Button); + + dismiss.onClick = (_) -> + { + dismissNotification(); + }; + } + + function buildComponent(path:String):Component + { + return RuntimeComponentBuilder.fromAsset(path); + } +} diff --git a/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx b/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx index a7c8cff5f..8c2419de5 100644 --- a/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx +++ b/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx @@ -2,21 +2,15 @@ package funkin.ui.stageBuildShit; import flixel.FlxSprite; import flixel.input.mouse.FlxMouseEvent; -<<<<<<< HEAD import flixel.input.mouse.FlxMouseEventManager; -======= ->>>>>>> origin/feature/week-4-gameplay import flixel.math.FlxPoint; import funkin.play.PlayState; -<<<<<<< HEAD import funkin.play.stage.StageData.StageDataParser; import funkin.play.stage.StageData; import haxe.ui.RuntimeComponentBuilder; import haxe.ui.containers.VBox; -======= import funkin.play.stage.StageData; import haxe.ui.RuntimeComponentBuilder; ->>>>>>> origin/feature/week-4-gameplay import haxe.ui.core.Component; import openfl.events.Event; import openfl.events.IOErrorEvent; diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx index bf80e688f..c566d154c 100644 --- a/source/funkin/util/WindowUtil.hx +++ b/source/funkin/util/WindowUtil.hx @@ -2,35 +2,54 @@ package funkin.util; import flixel.util.FlxSignal.FlxTypedSignal; +#if cpp +@:cppFileCode(' +#include +#include +#include +') +#end class WindowUtil { - public static function openURL(targetUrl:String) - { - #if CAN_OPEN_LINKS - #if linux - // Sys.command('/usr/bin/xdg-open', [, "&"]); - Sys.command('/usr/bin/xdg-open', [targetUrl, "&"]); - #else - FlxG.openURL(targetUrl); - #end - #else - trace('Cannot open'); - #end - } + public static function openURL(targetUrl:String) + { + #if CAN_OPEN_LINKS + #if linux + // Sys.command('/usr/bin/xdg-open', [, "&"]); + Sys.command('/usr/bin/xdg-open', [targetUrl, "&"]); + #else + FlxG.openURL(targetUrl); + #end + #else + trace('Cannot open'); + #end + } - /** - * Dispatched when the game window is closed. - */ - public static final windowExit:FlxTypedSignalVoid> = new FlxTypedSignalVoid>(); + /** + * Dispatched when the game window is closed. + */ + public static final windowExit:FlxTypedSignalVoid> = new FlxTypedSignalVoid>(); - public static function initWindowEvents() - { - // onUpdate is called every frame just before rendering. + public static function initWindowEvents() + { + // onUpdate is called every frame just before rendering. - // onExit is called when the game window is closed. - openfl.Lib.current.stage.application.onExit.add(function(exitCode:Int) - { - windowExit.dispatch(exitCode); - }); - } + // onExit is called when the game window is closed. + openfl.Lib.current.stage.application.onExit.add(function(exitCode:Int) + { + windowExit.dispatch(exitCode); + }); + } + + /** + * Turns off that annoying "Report to Microsoft" dialog that pops up when the game crashes. + */ + public static function disableCrashHandler() + { + #if cpp + untyped __cpp__('SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX);'); + #else + // Do nothing. + #end + } } diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx index 3e95e92c3..afa041d5b 100644 --- a/source/funkin/util/assets/DataAssets.hx +++ b/source/funkin/util/assets/DataAssets.hx @@ -4,7 +4,7 @@ class DataAssets { static function buildDataPath(path:String):String { - return 'default:assets/data/${path}'; + return 'assets/data/${path}'; } public static function listDataFilesInPath(path:String, ?suffix:String = '.json'):Array diff --git a/source/funkin/util/macro/ClassMacro.hx b/source/funkin/util/macro/ClassMacro.hx new file mode 100644 index 000000000..4589eaac8 --- /dev/null +++ b/source/funkin/util/macro/ClassMacro.hx @@ -0,0 +1,204 @@ +package funkin.util.macro; + +import haxe.macro.Context; +import haxe.macro.Expr; +import haxe.macro.Type; +import funkin.util.macro.MacroUtil; + +/** + * Macros to generate lists of classes at compile time. + * + * This code is a bitch glad Jason figured it out. + * Based on code from CompileTime: https://github.com/jasononeil/compiletime + */ +class ClassMacro +{ + /** + * Gets a list of `Class` for all classes in a specified package. + * + * Example: `var list:Array> = listClassesInPackage("funkin", true);` + * + * @param targetPackage A String containing the package name to query. + * @param includeSubPackages Whether to include classes located in sub-packages of the target package. + * @return A list of classes matching the specified criteria. + */ + public static macro function listClassesInPackage(targetPackage:String, ?includeSubPackages:Bool = true):ExprOf>> + { + if (!onGenerateCallbackRegistered) + { + onGenerateCallbackRegistered = true; + Context.onGenerate(onGenerate); + } + + var request:String = 'package~${targetPackage}~${includeSubPackages ? "recursive" : "nonrecursive"}'; + + classListsToGenerate.push(request); + + return macro funkin.util.macro.CompiledClassList.get($v{request}); + } + + /** + * Get a list of `Class` for all classes extending a specified class. + * + * Example: `var list:Array> = listSubclassesOf(FlxSprite);` + * + * @param targetClass The class to query for subclasses. + * @return A list of classes matching the specified criteria. + */ + public static macro function listSubclassesOf(targetClassExpr:ExprOf>):ExprOf>> + { + if (!onGenerateCallbackRegistered) + { + onGenerateCallbackRegistered = true; + Context.onGenerate(onGenerate); + } + + var targetClass:ClassType = MacroUtil.getClassTypeFromExpr(targetClassExpr); + var targetClassPath:String = null; + if (targetClass != null) + targetClassPath = targetClass.pack.join('.') + '.' + targetClass.name; + + var request:String = 'extend~${targetClassPath}'; + + classListsToGenerate.push(request); + + return macro funkin.util.macro.CompiledClassList.getTyped($v{request}, ${targetClassExpr}); + } + + #if macro + /** + * Callback executed after the typing phase but before the generation phase. + * Receives a list of `haxe.macro.Type` for all types in the program. + * + * Only metadata can be modified at this time, which makes it a BITCH to access the data at runtime. + */ + static function onGenerate(allTypes:Array) + { + // Reset these, since onGenerate persists across multiple builds. + classListsRaw = []; + + for (request in classListsToGenerate) + { + classListsRaw.set(request, []); + } + + for (type in allTypes) + { + switch (type) + { + // Class instances + case TInst(t, _params): + var classType:ClassType = t.get(); + var className:String = t.toString(); + + if (classType.isInterface) + { + // Ignore interfaces. + } + else + { + for (request in classListsToGenerate) + { + if (doesClassMatchRequest(classType, request)) + { + classListsRaw.get(request).push(className); + } + } + } + // Other types (things like enums) + default: + continue; + } + } + + compileClassLists(); + } + + /** + * At this stage in the program, `classListsRaw` is generated, but only accessible by macros. + * To make it accessible at runtime, we must: + * - Convert the String names to actual `Class` instances, and store it as `classLists` + * - Insert the `classLists` into the metadata of the `CompiledClassList` class. + * `CompiledClassList` then extracts the metadata and stores it where it can be accessed at runtime. + */ + static function compileClassLists() + { + var compiledClassList:ClassType = MacroUtil.getClassType("funkin.util.macro.CompiledClassList"); + + if (compiledClassList == null) + throw "Could not find CompiledClassList class."; + + // Reset outdated metadata. + if (compiledClassList.meta.has('classLists')) + compiledClassList.meta.remove('classLists'); + + var classLists:Array = []; + // Generate classLists. + for (request in classListsToGenerate) + { + // Expression contains String, [Class...] + var classListEntries:Array = [macro $v{request}]; + for (i in classListsRaw.get(request)) + { + // TODO: Boost performance by making this an Array> instead of an Array + // How to perform perform macro reificiation to types given a name? + classListEntries.push(macro $v{i}); + } + + classLists.push(macro $a{classListEntries}); + } + + // Insert classLists into metadata. + compiledClassList.meta.add('classLists', classLists, Context.currentPos()); + } + + static function doesClassMatchRequest(classType:ClassType, request:String):Bool + { + var splitRequest:Array = request.split('~'); + + var requestType:String = splitRequest[0]; + + switch (requestType) + { + case 'package': + var targetPackage:String = splitRequest[1]; + var recursive:Bool = splitRequest[2] == 'recursive'; + + var classPackage:String = classType.pack.join('.'); + + if (recursive) + { + return StringTools.startsWith(classPackage, targetPackage); + } + else + { + var regex:EReg = ~/^${targetPackage}(\.|$)/; + return regex.match(classPackage); + } + case 'extend': + var targetClassName:String = splitRequest[1]; + + var targetClassType:ClassType = MacroUtil.getClassType(targetClassName); + + if (MacroUtil.implementsInterface(classType, targetClassType)) + { + return true; + } + else if (MacroUtil.isSubclassOf(classType, targetClassType)) + { + return true; + } + + return false; + + default: + throw 'Unknown request type: ${requestType}'; + } + } + + static var onGenerateCallbackRegistered:Bool = false; + + static var classListsRaw:Map> = []; + static var classListsToGenerate:Array = []; + #end +} diff --git a/source/funkin/util/macro/CompiledClassList.hx b/source/funkin/util/macro/CompiledClassList.hx new file mode 100644 index 000000000..d7a50668e --- /dev/null +++ b/source/funkin/util/macro/CompiledClassList.hx @@ -0,0 +1,69 @@ +package funkin.util.macro; + +import haxe.rtti.Meta; + +/** + * A complement to `ClassMacro`. See `ClassMacro` for more information. + */ +class CompiledClassList +{ + static var classLists:Map>>; + + /** + * Class lists are injected into this class's metadata during the typing phase. + * This function extracts the metadata, at runtime, and stores it in `classLists`. + */ + static function init():Void + { + classLists = []; + + // Meta.getType returns Dynamic>. + var metaData = Meta.getType(CompiledClassList); + + if (metaData.classLists != null) + { + for (list in metaData.classLists) + { + var data:Array = cast list; + + // First element is the list ID. + var id:String = cast data[0]; + + // All other elements are class types. + var classes:List> = new List(); + for (i in 1...data.length) + { + var className:String = cast data[i]; + // var classType:Class = cast data[i]; + var classType:Class = cast Type.resolveClass(className); + classes.push(classType); + } + + classLists.set(id, classes); + } + } + else + { + throw "Class lists not properly generated. Try cleaning out your export folder, restarting your IDE, and rebuilding your project."; + } + } + + public static function get(request:String):List> + { + if (classLists == null) + init(); + + if (!classLists.exists(request)) + { + trace('[WARNING] Class list $request not properly generated. Please debug the build macro.'); + classLists.set(request, new List()); // Make the error only appear once. + } + + return classLists.get(request); + } + + public static inline function getTyped(request:String, type:Class):List> + { + return cast get(request); + } +} diff --git a/source/funkin/util/macro/GitCommit.hx b/source/funkin/util/macro/GitCommit.hx index a9b6a6b65..0449857cd 100644 --- a/source/funkin/util/macro/GitCommit.hx +++ b/source/funkin/util/macro/GitCommit.hx @@ -3,59 +3,65 @@ package funkin.util.macro; #if debug class GitCommit { - public static macro function getGitCommitHash():haxe.macro.Expr.ExprOf - { - #if !display - // Get the current line number. - var pos = haxe.macro.Context.currentPos(); + /** + * Get the SHA1 hash of the current Git commit. + */ + public static macro function getGitCommitHash():haxe.macro.Expr.ExprOf + { + #if !display + // Get the current line number. + var pos = haxe.macro.Context.currentPos(); - var process = new sys.io.Process('git', ['rev-parse', 'HEAD']); - if (process.exitCode() != 0) - { - var message = process.stderr.readAll().toString(); - haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos); - } + var process = new sys.io.Process('git', ['rev-parse', 'HEAD']); + if (process.exitCode() != 0) + { + var message = process.stderr.readAll().toString(); + haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos); + } - // read the output of the process - var commitHash:String = process.stdout.readLine(); - var commitHashSplice:String = commitHash.substr(0, 7); + // read the output of the process + var commitHash:String = process.stdout.readLine(); + var commitHashSplice:String = commitHash.substr(0, 7); - trace('Git Commit ID: ${commitHashSplice}'); + trace('Git Commit ID: ${commitHashSplice}'); - // Generates a string expression - return macro $v{commitHashSplice}; - #else - // `#if display` is used for code completion. In this case returning an - // empty string is good enough; We don't want to call git on every hint. - var commitHash:String = ""; - return macro $v{commitHashSplice}; - #end - } + // Generates a string expression + return macro $v{commitHashSplice}; + #else + // `#if display` is used for code completion. In this case returning an + // empty string is good enough; We don't want to call git on every hint. + var commitHash:String = ""; + return macro $v{commitHashSplice}; + #end + } - public static macro function getGitBranch():haxe.macro.Expr.ExprOf - { - #if !display - // Get the current line number. - var pos = haxe.macro.Context.currentPos(); - var branchProcess = new sys.io.Process('git', ['rev-parse', '--abbrev-ref', 'HEAD']); + /** + * Get the branch name of the current Git commit. + */ + public static macro function getGitBranch():haxe.macro.Expr.ExprOf + { + #if !display + // Get the current line number. + var pos = haxe.macro.Context.currentPos(); + var branchProcess = new sys.io.Process('git', ['rev-parse', '--abbrev-ref', 'HEAD']); - if (branchProcess.exitCode() != 0) - { - var message = branchProcess.stderr.readAll().toString(); - haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos); - } + if (branchProcess.exitCode() != 0) + { + var message = branchProcess.stderr.readAll().toString(); + haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos); + } - var branchName:String = branchProcess.stdout.readLine(); - trace('Git Branch Name: ${branchName}'); + var branchName:String = branchProcess.stdout.readLine(); + trace('Git Branch Name: ${branchName}'); - // Generates a string expression - return macro $v{branchName}; - #else - // `#if display` is used for code completion. In this case returning an - // empty string is good enough; We don't want to call git on every hint. - var branchName:String = ""; - return macro $v{branchName}; - #end - } + // Generates a string expression + return macro $v{branchName}; + #else + // `#if display` is used for code completion. In this case returning an + // empty string is good enough; We don't want to call git on every hint. + var branchName:String = ""; + return macro $v{branchName}; + #end + } } #end diff --git a/source/funkin/util/macro/MacroUtil.hx b/source/funkin/util/macro/MacroUtil.hx index ebf5bd5ec..1edf07e8f 100644 --- a/source/funkin/util/macro/MacroUtil.hx +++ b/source/funkin/util/macro/MacroUtil.hx @@ -1,12 +1,173 @@ package funkin.util.macro; +import haxe.macro.Context; +import haxe.macro.Expr; +import haxe.macro.Type; + class MacroUtil { - public static macro function getDefine(key:String, defaultValue:String = null):haxe.macro.Expr - { - var value = haxe.macro.Context.definedValue(key); - if (value == null) - value = defaultValue; - return macro $v{value}; - } + /** + * Gets the value of a Haxe compiler define. + * + * @param key The name of the define to get the value of. + * @param defaultValue The value to return if the define is not set. + * @return An expression containing the value of the define. + */ + public static macro function getDefine(key:String, defaultValue:String = null):haxe.macro.Expr + { + var value = haxe.macro.Context.definedValue(key); + if (value == null) + value = defaultValue; + return macro $v{value}; + } + + /** + * Gets the current date and time (at compile time). + * @return A `Date` object containing the current date and time. + */ + public static macro function getDate():ExprOf + { + var date = Date.now(); + var year = toExpr(date.getFullYear()); + var month = toExpr(date.getMonth()); + var day = toExpr(date.getDate()); + var hours = toExpr(date.getHours()); + var mins = toExpr(date.getMinutes()); + var secs = toExpr(date.getSeconds()); + return macro new Date($year, $month, $day, $hours, $mins, $secs); + } + + #if macro + // + // MACRO HELPER FUNCTIONS + // + + /** + * Convert an ExprOf> to a ClassType. + * @see https://github.com/jasononeil/compiletime/blob/master/src/CompileTime.hx#L201 + */ + public static function getClassTypeFromExpr(e:Expr):ClassType + { + var classType:ClassType = null; + + var parts:Array = []; + var nextSection:ExprDef = e.expr; + + while (nextSection != null) + { + var section:ExprDef = nextSection; + nextSection = null; + + switch (section) + { + // Expression is a class name with no packages + case EConst(c): + switch (c) + { + case CIdent(cn): + if (cn != "null") parts.unshift(cn); + default: + } + // Expression is a fully qualified package name. + // We need to traverse the expression tree to get the full package name. + case EField(exp, field): + nextSection = exp.expr; + parts.unshift(field); + + // We've reached the end of the expression tree. + default: + } + } + + var fullClassName:String = parts.join('.'); + if (fullClassName != "") + { + var classType:Type = Context.getType(fullClassName); + // Follow typedefs to get the actual class type. + var classTypeParsed:Type = Context.follow(classType, false); + + switch (classTypeParsed) + { + case TInst(t, params): + return t.get(); + default: + // We couldn't parse this class type. + // This function may need to be updated to be more robust. + throw 'Class type could not be parsed: ${fullClassName}'; + } + } + + return null; + } + + /** + * Converts a value to an equivalent macro expression. + */ + public static function toExpr(value:Dynamic):ExprOf + { + return Context.makeExpr(value, Context.currentPos()); + } + + public static function areClassesEqual(class1:ClassType, class2:ClassType):Bool + { + return class1.pack.join('.') == class2.pack.join('.') && class1.name == class2.name; + } + + /** + * Retrieve a ClassType from a string name. + */ + public static function getClassType(name:String):ClassType + { + switch (Context.getType(name)) + { + case TInst(t, _params): + return t.get(); + default: + throw 'Class type could not be parsed: ${name}'; + } + } + + /** + * Determine whether a given ClassType is a subclass of a given superclass. + * @param classType The class to check. + * @param superClass The superclass to check for. + * @return Whether the class is a subclass of the superclass. + */ + public static function isSubclassOf(classType:ClassType, superClass:ClassType):Bool + { + if (areClassesEqual(classType, superClass)) + return true; + + if (classType.superClass != null) + { + return isSubclassOf(classType.superClass.t.get(), superClass); + } + + return false; + } + + /** + * Determine whether a given ClassType implements a given interface. + * @param classType The class to check. + * @param interfaceType The interface to check for. + * @return Whether the class implements the interface. + */ + public static function implementsInterface(classType:ClassType, interfaceType:ClassType):Bool + { + for (i in classType.interfaces) + { + if (areClassesEqual(i.t.get(), interfaceType)) + { + return true; + } + } + + if (classType.superClass != null) + { + return implementsInterface(classType.superClass.t.get(), interfaceType); + } + + return false; + } + #end } diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx new file mode 100644 index 000000000..296a818c7 --- /dev/null +++ b/source/funkin/util/tools/MapTools.hx @@ -0,0 +1,16 @@ +package funkin.util.tools; + +/** + * A static extension which provides utility functions for Maps. + * + * For example, add `using MapTools` then call `map.values()`. + * + * @see https://haxe.org/manual/lf-static-extension.html + */ +class MapTools +{ + public static function values(map:Map):Array + { + return [for (i in map.iterator()) i]; + } +}