diff --git a/docs/style-guide.md b/docs/style-guide.md new file mode 100644 index 000000000..71ae844c4 --- /dev/null +++ b/docs/style-guide.md @@ -0,0 +1,54 @@ +# Funkin' Repo Code Style Guide + +This short document is designed to give a run-down on how code should be formatted to maintain a consistent style throughout, making the repo easier to maintain. + +## Notes on IDEs and Extensions + +The Visual Studio Code IDE is highly recommended, as this repo contains various configuration that works with VSCode extensions to automatically style certain things for you. + +VSCode is also the only IDE that has any good tools for Haxe so yeah. + +## Whitespace and Indentation + +The Haxe extension of VSCode will use the repo's `hxformat.json` to tell VSCode how to format the code files of the repo. Enabling VSCode's "Format on Save" feature is recommended. + +## Variable and Function Names + +It is recommended to name variables and functions with descriptive titles, in lowerCamelCase. There is no penalty for giving a variable a longer name as long as it isn't excessive. + +## Code Comments + +The CodeDox extension for VSCode provides extensive support for JavaDoc-style code comments, and these should be used for public functions wherever possible to ensure the usage of each function is clear. + +Example: +``` +/** + * Finds the largest deviation from the desired time inside this VoicesGroup. + * + * @param targetTime The time to check against. + * If none is provided, it checks the time of all members against the first member of this VoicesGroup. + * @return The largest deviation from the target time found. + */ +public function checkSyncError(?targetTime:Float):Float +``` + +## License Headers + +Do not include headers specifying code license on individual files in the repo, since the main `LICENSE.md` file covers all of them. + +## Imports + +Imports should be placed in a single group, in alphabetical order, at the top of the code file. The exception is conditional imports (using compile defines), which should be placed at the end of the list (and sorted alphabetically where possible). + +Example: +``` +import haxe.format.JsonParser; +import openfl.Assets; +import openfl.geom.Matrix; +import openfl.geom.Matrix3D; +#if sys +import funkin.io.FileUtil; +import sys.io.File; +#end +``` + diff --git a/hmm.json b/hmm.json index b570bdc04..6bc9eadf5 100644 --- a/hmm.json +++ b/hmm.json @@ -1,104 +1,105 @@ { - "dependencies": [{ - "name": "discord_rpc", - "type": "git", - "dir": null, - "ref": "2d83fa8", - "url": "https://github.com/Aidan63/linc_discord-rpc" - }, - { - "name": "flixel", - "type": "git", - "dir": null, - "ref": "a629f9a5", - "url": "https://github.com/MasterEric/flixel" - }, - { - "name": "flixel-addons", - "type": "git", - "dir": null, - "ref": "752c3d7", - "url": "https://github.com/MasterEric/flixel-addons" - }, - { - "name": "flixel-ui", - "type": "haxelib", - "version": "2.4.0" - }, - { - "name": "flxanimate", - "type": "git", - "dir": null, - "ref": "18b2060", - "url": "https://github.com/Dot-Stuff/flxanimate" - }, - { - "name": "format", - "type": "haxelib", - "version": "3.5.0" - }, - { - "name": "haxeui-core", - "type": "git", - "dir": null, - "ref": "fc8d656b", - "url": "https://github.com/haxeui/haxeui-core/" - }, - { - "name": "haxeui-flixel", - "type": "git", - "dir": null, - "ref": "80941a7", - "url": "https://github.com/haxeui/haxeui-flixel" - }, - { - "name": "hmm", - "type": "haxelib", - "version": "2.1.0" - }, - { - "name": "hscript", - "type": "haxelib", - "version": "2.5.0" - }, - { - "name": "hxcpp", - "type": "haxelib", - "version": "4.2.1" - }, - { - "name": "hxcpp-debug-server", - "type": "haxelib", - "version": "1.2.4" - }, - { - "name": "hxp", - "type": "haxelib", - "version": "1.2.2" - }, - { - "name": "lime", - "type": "haxelib", - "version": "8.0.0" - }, - { - "name": "openfl", - "type": "git", - "dir": null, - "ref": "3fd5763c1", - "url": "https://github.com/MasterEric/openfl" - }, - { - "name": "polymod", - "type": "git", - "dir": null, - "ref": "547c8ee", - "url": "https://github.com/larsiusprime/polymod" - }, - { - "name": "thx.semver", - "type": "haxelib", - "version": "0.2.2" - } - ] + "dependencies": [ + { + "name": "discord_rpc", + "type": "git", + "dir": null, + "ref": "2d83fa8", + "url": "https://github.com/Aidan63/linc_discord-rpc" + }, + { + "name": "flixel", + "type": "git", + "dir": null, + "ref": "a629f9a5", + "url": "https://github.com/MasterEric/flixel" + }, + { + "name": "flixel-addons", + "type": "git", + "dir": null, + "ref": "752c3d7", + "url": "https://github.com/MasterEric/flixel-addons" + }, + { + "name": "flixel-ui", + "type": "haxelib", + "version": "2.4.0" + }, + { + "name": "flxanimate", + "type": "git", + "dir": null, + "ref": "18b2060", + "url": "https://github.com/Dot-Stuff/flxanimate" + }, + { + "name": "format", + "type": "haxelib", + "version": "3.5.0" + }, + { + "name": "haxeui-core", + "type": "git", + "dir": null, + "ref": "e5cf78d", + "url": "https://github.com/haxeui/haxeui-core/" + }, + { + "name": "haxeui-flixel", + "type": "git", + "dir": null, + "ref": "f03bb6d", + "url": "https://github.com/haxeui/haxeui-flixel" + }, + { + "name": "hmm", + "type": "haxelib", + "version": "2.1.0" + }, + { + "name": "hscript", + "type": "haxelib", + "version": "2.5.0" + }, + { + "name": "hxcpp", + "type": "haxelib", + "version": "4.2.1" + }, + { + "name": "hxcpp-debug-server", + "type": "haxelib", + "version": "1.2.4" + }, + { + "name": "hxp", + "type": "haxelib", + "version": null + }, + { + "name": "lime", + "type": "haxelib", + "version": null + }, + { + "name": "openfl", + "type": "git", + "dir": null, + "ref": "3fd5763c1", + "url": "https://github.com/MasterEric/openfl" + }, + { + "name": "polymod", + "type": "git", + "dir": null, + "ref": "547c8ee", + "url": "https://github.com/larsiusprime/polymod" + }, + { + "name": "thx.semver", + "type": "haxelib", + "version": "0.2.2" + } + ] } \ No newline at end of file diff --git a/source/Main.hx b/source/Main.hx index 113a101a2..5043393f1 100644 --- a/source/Main.hx +++ b/source/Main.hx @@ -78,7 +78,13 @@ class Main extends Sprite */ #if !debug - initialState = funkin.TitleState; + /** + * Someone was like "hey let's make a state that only runs code on debug builds" + * then put essential initialization code in it. + * The easiest fix is to make it run in all builds. + * -Eric + */ + // initialState = funkin.TitleState; #end initHaxeUI(); diff --git a/source/funkin/Alphabet.hx b/source/funkin/Alphabet.hx index cba2025a6..85861720a 100644 --- a/source/funkin/Alphabet.hx +++ b/source/funkin/Alphabet.hx @@ -5,8 +5,6 @@ import flixel.group.FlxSpriteGroup; import flixel.math.FlxMath; import flixel.util.FlxTimer; -using StringTools; - /** * Loosley based on FlxTypeText lolol */ diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index 9a95a9863..425ce25ae 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -63,13 +63,33 @@ class Conductor } /** - * Duration of a step in milliseconds. Calculated based on bpm. + * 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 / 4; + return crochet / timeSignatureNumerator; + } + + public static var timeSignatureNumerator(get, null):Int; + + static function get_timeSignatureNumerator():Int + { + if (currentTimeChange == null) + return 4; + + return currentTimeChange.timeSignatureNum; + } + + public static var timeSignatureDenominator(get, null):Int; + + static function get_timeSignatureDenominator():Int + { + if (currentTimeChange == null) + return 4; + + return currentTimeChange.timeSignatureDen; } /** @@ -96,7 +116,20 @@ class Conductor public static var offset:Float = 0; // TODO: Add code to update this. - public static var beatsPerMeasure:Int = 4; + public static var beatsPerMeasure(get, null):Int; + + static function get_beatsPerMeasure():Int + { + return timeSignatureNumerator; + } + + public static var stepsPerMeasure(get, null):Int; + + static function get_stepsPerMeasure():Int + { + // Is this always x4? + return timeSignatureNumerator * 4; + } private function new() { @@ -124,11 +157,17 @@ class Conductor * 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) + 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; } @@ -213,10 +252,8 @@ class Conductor } } - public static function mapTimeChanges(currentChart:SongDifficulty) + public static function mapTimeChanges(songTimeChanges:Array) { - var songTimeChanges:Array = currentChart.timeChanges; - timeChanges = []; for (currentTimeChange in songTimeChanges) diff --git a/source/funkin/CoolUtil.hx b/source/funkin/CoolUtil.hx index a35364e5f..fbe703803 100644 --- a/source/funkin/CoolUtil.hx +++ b/source/funkin/CoolUtil.hx @@ -18,8 +18,6 @@ import lime.math.Rectangle; import lime.utils.Assets; import openfl.filters.ShaderFilter; -using StringTools; - class CoolUtil { public static var difficultyArray:Array = ['EASY', "NORMAL", "HARD"]; diff --git a/source/funkin/CutsceneCharacter.hx b/source/funkin/CutsceneCharacter.hx index cf2caa870..37fd190af 100644 --- a/source/funkin/CutsceneCharacter.hx +++ b/source/funkin/CutsceneCharacter.hx @@ -4,8 +4,6 @@ import flixel.FlxSprite; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.math.FlxPoint; -using StringTools; - class CutsceneCharacter extends FlxTypedGroup { public var coolPos:FlxPoint = FlxPoint.get(); diff --git a/source/funkin/DialogueBox.hx b/source/funkin/DialogueBox.hx index 599077563..663b7fdba 100644 --- a/source/funkin/DialogueBox.hx +++ b/source/funkin/DialogueBox.hx @@ -11,8 +11,6 @@ import flixel.util.FlxColor; import flixel.util.FlxTimer; import funkin.play.PlayState; -using StringTools; - class DialogueBox extends FlxSpriteGroup { var box:FlxSprite; diff --git a/source/funkin/Discord.hx b/source/funkin/Discord.hx index 34fce44ed..3278a148d 100644 --- a/source/funkin/Discord.hx +++ b/source/funkin/Discord.hx @@ -1,9 +1,6 @@ package funkin; import Sys.sleep; - -using StringTools; - #if discord_rpc import discord_rpc.DiscordRpc; #end diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx index 5c9fb1a99..60d8f71c6 100644 --- a/source/funkin/FreeplayState.hx +++ b/source/funkin/FreeplayState.hx @@ -36,8 +36,6 @@ import funkin.shaderslmfao.StrokeShader; import lime.app.Future; import lime.utils.Assets; -using StringTools; - class FreeplayState extends MusicBeatSubstate { var songs:Array = []; diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index cfad5374f..7f192c170 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -1,5 +1,6 @@ package funkin; +import flixel.system.debug.log.LogStyle; import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond; import flixel.addons.transition.FlxTransitionableState; import flixel.addons.transition.TransitionData; @@ -15,10 +16,8 @@ import funkin.play.song.SongData.SongDataParser; import funkin.play.stage.StageData; import funkin.ui.PreferencesMenu; import funkin.util.macro.MacroUtil; +import funkin.util.WindowUtil; import openfl.display.BitmapData; - -using StringTools; - #if colyseus import io.colyseus.Client; import io.colyseus.Room; @@ -88,8 +87,16 @@ class InitState extends FlxTransitionableState 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; + // FlxG.save.close(); // FlxG.sound.loadSavedPrefs(); + WindowUtil.initWindowEvents(); + PreferencesMenu.initPrefs(); PlayerSettings.init(); Highscore.load(); @@ -125,6 +132,8 @@ class InitState extends FlxTransitionableState ModuleHandler.buildModuleCallbacks(); ModuleHandler.loadModuleCache(); + FlxG.debugger.toggleKeys = [F2]; + #if song var song = getSong(); diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx index a62cdf199..015f7103f 100644 --- a/source/funkin/LatencyState.hx +++ b/source/funkin/LatencyState.hx @@ -31,8 +31,10 @@ class LatencyState extends MusicBeatSubstate var offsetsPerBeat:Array = []; var swagSong:HomemadeMusic; + #if debug var funnyStatsGraph:CoolStatsGraph; var realStats:CoolStatsGraph; + #end override function create() { @@ -42,11 +44,13 @@ class LatencyState extends MusicBeatSubstate FlxG.sound.music = swagSong; FlxG.sound.music.play(); + #if debug funnyStatsGraph = new CoolStatsGraph(0, Std.int(FlxG.height / 2), FlxG.width, Std.int(FlxG.height / 2), FlxColor.PINK, "time"); FlxG.addChildBelowMouse(funnyStatsGraph); realStats = new CoolStatsGraph(0, Std.int(FlxG.height / 2), FlxG.width, Std.int(FlxG.height / 2), FlxColor.YELLOW, "REAL"); FlxG.addChildBelowMouse(realStats); + #end FlxG.stage.addEventListener(KeyboardEvent.KEY_DOWN, key -> { @@ -170,8 +174,10 @@ class LatencyState extends MusicBeatSubstate trace(FlxG.sound.music._channel.position); */ + #if debug funnyStatsGraph.update(FlxG.sound.music.time % 500); realStats.update(swagSong.getTimeWithDiff() % 500); + #end if (FlxG.keys.justPressed.S) { diff --git a/source/funkin/LoadingState.hx b/source/funkin/LoadingState.hx index 3c60d34df..688ea96ea 100644 --- a/source/funkin/LoadingState.hx +++ b/source/funkin/LoadingState.hx @@ -188,10 +188,13 @@ class LoadingState extends MusicBeatState { Paths.setCurrentLevel('tutorial'); } - else if (PlayState.storyWeek == 8) { + else if (PlayState.storyWeek == 8) + { // TODO: Refactor this code. Paths.setCurrentLevel("weekend1"); - } else { + } + else + { Paths.setCurrentLevel("week" + PlayState.storyWeek); } #if NO_PRELOAD_ALL @@ -251,7 +254,7 @@ class LoadingState extends MusicBeatState } else { - if (StringTools.endsWith(path, ".bundle")) + if (path.endsWith(".bundle")) { rootPath = path; path += "/library.json"; @@ -351,5 +354,5 @@ class MultiCallback return fired.copy(); public function getUnfired() - return [for (id in unfired.keys()) id]; + return unfired.array(); } diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx index 715793012..d2ca2657e 100644 --- a/source/funkin/MainMenuState.hx +++ b/source/funkin/MainMenuState.hx @@ -28,9 +28,6 @@ import funkin.util.Constants; import funkin.util.WindowUtil; import lime.app.Application; import openfl.filters.ShaderFilter; - -using StringTools; - #if discord_rpc import Discord.DiscordClient; #end diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx index 9c6b39ab6..2caa14aac 100644 --- a/source/funkin/MusicBeatState.hx +++ b/source/funkin/MusicBeatState.hx @@ -60,6 +60,7 @@ class MusicBeatState extends FlxUIState { super.update(elapsed); + // Emergency exit button. if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState()); @@ -67,7 +68,7 @@ class MusicBeatState extends FlxUIState if (FlxG.keys.justPressed.F5) debug_refreshModules(); - // ` / ~ + // ` / ~ to open the debug menu. if (FlxG.keys.justPressed.GRAVEACCENT) { // TODO: Does this break anything? @@ -76,7 +77,10 @@ class MusicBeatState extends FlxUIState FlxG.state.openSubState(new DebugMenuSubState()); } + // Display Conductor info in the watch window. FlxG.watch.addQuick("songPos", Conductor.songPosition); + FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime); + FlxG.watch.addQuick("bpm", Conductor.bpm); dispatchEvent(new UpdateScriptEvent(elapsed)); } diff --git a/source/funkin/NGio.hx b/source/funkin/NGio.hx index b0b429f6c..fdab9507d 100644 --- a/source/funkin/NGio.hx +++ b/source/funkin/NGio.hx @@ -15,8 +15,6 @@ import io.newgrounds.objects.events.Result.GetCurrentVersionResult; import io.newgrounds.objects.events.Result.GetVersionResult; import lime.app.Application; import openfl.display.Stage; - -using StringTools; #end /** diff --git a/source/funkin/Note.hx b/source/funkin/Note.hx index 916e3bf8e..144614757 100644 --- a/source/funkin/Note.hx +++ b/source/funkin/Note.hx @@ -10,8 +10,6 @@ import funkin.shaderslmfao.ColorSwap; import funkin.ui.PreferencesMenu; import funkin.util.Constants; -using StringTools; - class Note extends FlxSprite { public var data = new NoteData(); diff --git a/source/funkin/SongLoad.hx b/source/funkin/SongLoad.hx index b4cbc3c26..fc786977f 100644 --- a/source/funkin/SongLoad.hx +++ b/source/funkin/SongLoad.hx @@ -6,8 +6,6 @@ import funkin.play.PlayState; import haxe.Json; import lime.utils.Assets; -using StringTools; - typedef SwagSong = { var song:String; diff --git a/source/funkin/StoryMenuState.hx b/source/funkin/StoryMenuState.hx index f988c462f..439501ce8 100644 --- a/source/funkin/StoryMenuState.hx +++ b/source/funkin/StoryMenuState.hx @@ -15,9 +15,6 @@ import funkin.play.PlayState; import funkin.play.song.SongData.SongDataParser; import lime.net.curl.CURLCode; import openfl.Assets; - -using StringTools; - #if discord_rpc import Discord.DiscordClient; #end diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx index 48f5d9550..6db3434cb 100644 --- a/source/funkin/TitleState.hx +++ b/source/funkin/TitleState.hx @@ -23,8 +23,6 @@ import openfl.events.NetStatusEvent; import openfl.media.Video; import openfl.net.NetStream; -using StringTools; - #if desktop #end class TitleState extends MusicBeatState diff --git a/source/funkin/VoicesGroup.hx b/source/funkin/VoicesGroup.hx index 306d82900..db31cb052 100644 --- a/source/funkin/VoicesGroup.hx +++ b/source/funkin/VoicesGroup.hx @@ -7,30 +7,38 @@ import flixel.system.FlxSound; // when needed class VoicesGroup extends FlxTypedGroup { - public var time(default, set):Float = 0; + public var time(get, set):Float; - public var volume(default, set):Float = 1; + public var volume(get, set):Float; - public var pitch(default, set):Float = 1; + public var pitch(get, set):Float; // make it a group that you add to? - public function new(song:String, ?files:Array = null) + public function new() { super(); + } + + // TODO: Remove this. + public static function build(song:String, ?files:Array = null):VoicesGroup + { + var result = new VoicesGroup(); if (files == null) { // Add an empty voice. - add(new FlxSound()); - return; + result.add(new FlxSound()); + return result; } for (sndFile in files) { var snd:FlxSound = new FlxSound().loadEmbedded(Paths.voices(song, '$sndFile')); FlxG.sound.list.add(snd); // adds it to sound group for proper volumes - add(snd); // adds it to main group for other shit + result.add(snd); // adds it to main group for other shit } + + return result; } /** @@ -83,6 +91,14 @@ class VoicesGroup extends FlxTypedGroup }); } + function get_time():Float + { + if (getFirstAlive() != null) + return getFirstAlive().time; + else + return 0; + } + function set_time(time:Float):Float { forEachAlive(function(snd) @@ -94,6 +110,14 @@ class VoicesGroup extends FlxTypedGroup return time; } + function get_volume():Float + { + if (getFirstAlive() != null) + return getFirstAlive().volume; + else + return 1; + } + // in PlayState, adjust the code so that it only mutes the player1 vocal tracks? function set_volume(volume:Float):Float { @@ -105,9 +129,20 @@ class VoicesGroup extends FlxTypedGroup return volume; } + function get_pitch():Float + { + #if FLX_PITCH + if (getFirstAlive() != null) + return getFirstAlive().pitch; + else + #end + return 1; + } + function set_pitch(val:Float):Float { - #if HAS_PITCH + #if FLX_PITCH + trace('Setting audio pitch to ' + val); forEachAlive(function(snd) { snd.pitch = val; diff --git a/source/funkin/api/newgrounds/NGUnsafe.hx b/source/funkin/api/newgrounds/NGUnsafe.hx index 2995988a9..bb2305a55 100644 --- a/source/funkin/api/newgrounds/NGUnsafe.hx +++ b/source/funkin/api/newgrounds/NGUnsafe.hx @@ -17,8 +17,6 @@ import io.newgrounds.objects.events.Result.GetCurrentVersionResult; import io.newgrounds.objects.events.Result.GetVersionResult; #end -using StringTools; - /** * Contains any script functions which should be BLOCKED from use by modded scripts. */ diff --git a/source/funkin/api/newgrounds/NGUtil.hx b/source/funkin/api/newgrounds/NGUtil.hx index 8ec06b27f..ef4081050 100644 --- a/source/funkin/api/newgrounds/NGUtil.hx +++ b/source/funkin/api/newgrounds/NGUtil.hx @@ -17,8 +17,6 @@ import io.newgrounds.objects.events.Result.GetCurrentVersionResult; import io.newgrounds.objects.events.Result.GetVersionResult; #end -using StringTools; - /** * Contains any script functions which should be ALLOWD for use by modded scripts. */ diff --git a/source/funkin/audio/FlxAudioGroup.hx b/source/funkin/audio/FlxAudioGroup.hx new file mode 100644 index 000000000..a06979a32 --- /dev/null +++ b/source/funkin/audio/FlxAudioGroup.hx @@ -0,0 +1,208 @@ +package funkin.audio; + +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.system.FlxSound; + +/** + * A group of FlxSounds which can be controlled as a whole. + * + * Add sounds to the group using `add()`, and then control them + * as a whole using the properties and methods of this class. + * + * It is assumed that all the sounds will play at the same time, + * and have the same duration. + */ +class FlxAudioGroup extends FlxTypedGroup +{ + /** + * The position in time of the sounds in the group. + * Measured in milliseconds. + */ + public var time(get, set):Float; + + function get_time():Float + { + if (getFirstAlive() != null) + return getFirstAlive().time; + else + return 0; + } + + function set_time(time:Float):Float + { + forEachAlive(function(sound:FlxSound) + { + // account for different offsets per sound? + sound.time = time; + }); + + return time; + } + + /** + * The volume of the sounds in the group. + */ + public var volume(get, set):Float; + + function get_volume():Float + { + if (getFirstAlive() != null) + return getFirstAlive().volume; + else + return 1.0; + } + + function set_volume(volume:Float):Float + { + forEachAlive(function(sound:FlxSound) + { + sound.volume = volume; + }); + + return volume; + } + + /** + * The pitch of the sounds in the group, as a multiplier of 1.0x. + * `2.0` would play the audio twice as fast with a higher pitch, + * and `0.5` would play the audio at half speed with a lower pitch. + */ + public var pitch(get, set):Float; + + function get_pitch():Float + { + #if FLX_PITCH + if (getFirstAlive() != null) + return getFirstAlive().pitch; + else + #end + return 1; + } + + function set_pitch(val:Float):Float + { + #if FLX_PITCH + trace('Setting audio pitch to ' + val); + forEachAlive(function(sound:FlxSound) + { + sound.pitch = val; + }); + #end + return val; + } + + /** + * Whether members of the group should be destroyed when they finish playing. + */ + public var autoDestroyMembers(default, set):Bool = false; + + function set_autoDestroyMembers(value:Bool):Bool + { + autoDestroyMembers = value; + forEachAlive(function(sound:FlxSound) + { + sound.autoDestroy = value; + }); + return value; + } + + /** + * Add a sound to the group. + */ + public override function add(sound:FlxSound):FlxSound + { + var result:FlxSound = super.add(sound); + + if (result == null) + return null; + + // Apply parameters to the new sound. + result.autoDestroy = this.autoDestroyMembers; + result.pitch = this.pitch; + result.volume = this.volume; + + // We have to play, then pause the sound to set the time, + // else the sound will restart immediately when played. + result.play(true, 0.0); + result.pause(); + result.time = this.time; + + return result; + } + + /** + * Pause all the sounds in the group. + */ + public function pause() + { + forEachAlive(function(sound:FlxSound) + { + sound.pause(); + }); + } + + /** + * Play all the sounds in the group. + */ + public function play(forceRestart:Bool = false, startTime:Float = 0.0, ?endTime:Float) + { + forEachAlive(function(sound:FlxSound) + { + sound.play(forceRestart, startTime, endTime); + }); + } + + /** + * Resume all the sounds in the group. + */ + public function resume() + { + forEachAlive(function(sound:FlxSound) + { + sound.resume(); + }); + } + + /** + * Stop all the sounds in the group. + */ + public function stop() + { + forEachAlive(function(sound:FlxSound) + { + sound.stop(); + }); + } + + public override function clear():Void { + this.stop(); + + super.clear(); + } + + /** + * Calculates the deviation of the sounds in the group from the target time. + * + * @param targetTime The time to compare the sounds to. + * If null, the current time of the first sound in the group is used. + * @return The largest deviation of the sounds in the group from the target time. + */ + public function calcDeviation(?targetTime:Float):Float + { + var deviation:Float = 0; + + forEachAlive(function(sound:FlxSound) + { + if (targetTime == null) + targetTime = sound.time; + else + { + var diff:Float = sound.time - targetTime; + if (Math.abs(diff) > Math.abs(deviation)) + deviation = diff; + } + }); + + return deviation; + } +} diff --git a/source/funkin/audio/VocalGroup.hx b/source/funkin/audio/VocalGroup.hx new file mode 100644 index 000000000..2dce0d4c0 --- /dev/null +++ b/source/funkin/audio/VocalGroup.hx @@ -0,0 +1,119 @@ +package funkin.audio; + +import flixel.system.FlxSound; + +/** + * An audio group that allows for specific control of vocal tracks. + */ +class VocalGroup extends FlxAudioGroup +{ + /** + * The player's vocal track. + */ + var playerVocals:FlxSound; + + /** + * The opponent's vocal track. + */ + var opponentVocals:FlxSound; + + /** + * The volume of the player's vocal track. + * Nore that this value is multiplied by the overall volume of the group. + */ + public var playerVolume(default, set):Float; + + function set_playerVolume(value:Float):Float + { + playerVolume = value; + if (playerVocals != null) + { + // Make sure volume is capped at 1.0. + playerVocals.volume = Math.min(playerVolume * this.volume, 1.0); + } + return playerVolume; + } + + /** + * The volume of the opponent's vocal track. + * Nore that this value is multiplied by the overall volume of the group. + */ + public var opponentVolume(default, set):Float; + + function set_opponentVolume(value:Float):Float + { + opponentVolume = value; + if (opponentVocals != null) + { + // Make sure volume is capped at 1.0. + opponentVocals.volume = opponentVolume * this.volume; + } + return opponentVolume; + } + + /** + * Sets up the player's vocal track. + * Stops and removes the existing player track if one exists. + */ + public function setPlayerVocals(sound:FlxSound):FlxSound + { + if (playerVocals != null) + { + playerVocals.stop(); + remove(playerVocals); + playerVocals = null; + } + + playerVocals = add(sound); + playerVocals.volume = this.playerVolume * this.volume; + + return playerVocals; + } + + /** + * Sets up the opponent's vocal track. + * Stops and removes the existing player track if one exists. + */ + public function setOpponentVocals(sound:FlxSound):FlxSound + { + if (opponentVocals != null) + { + opponentVocals.stop(); + remove(opponentVocals); + opponentVocals = null; + } + + opponentVocals = add(sound); + opponentVocals.volume = this.opponentVolume * this.volume; + + return opponentVocals; + } + + /** + * In this extension of FlxAudioGroup, there is a separate overall volume + * which affects all the members of the group. + */ + var _volume = 1.0; + + override function get_volume():Float + { + return _volume; + } + + override function set_volume(value:Float):Float + { + _volume = super.set_volume(value); + + if (playerVocals != null) + { + playerVocals.volume = playerVolume * _volume; + } + + if (opponentVocals != null) + { + opponentVocals.volume = opponentVolume * _volume; + } + + return _volume; + } +} diff --git a/source/funkin/charting/ChartingState.hx b/source/funkin/charting/ChartingState.hx index d51df7689..f6913d4ae 100644 --- a/source/funkin/charting/ChartingState.hx +++ b/source/funkin/charting/ChartingState.hx @@ -34,7 +34,6 @@ import openfl.events.IOErrorEvent; import openfl.net.FileReference; using Lambda; -using StringTools; using flixel.util.FlxSpriteUtil; // add in "compiler save" that saves the JSON directly to the debug json using File.write() stuff on windows / sys class ChartingState extends MusicBeatState @@ -445,7 +444,7 @@ class ChartingState extends MusicBeatState add(playheadTest); // WONT WORK FOR TUTORIAL OR TEST SONG!!! REDO LATER - vocals = new VoicesGroup(daSong, _song.voiceList); + vocals = VoicesGroup.build(daSong, _song.voiceList); // vocals = new FlxSound().loadEmbedded(Paths.voices(daSong)); // FlxG.sound.list.add(vocals); diff --git a/source/funkin/freeplayStuff/FreeplayScore.hx b/source/funkin/freeplayStuff/FreeplayScore.hx index 0a062f6b6..0a2d475ee 100644 --- a/source/funkin/freeplayStuff/FreeplayScore.hx +++ b/source/funkin/freeplayStuff/FreeplayScore.hx @@ -3,8 +3,6 @@ package funkin.freeplayStuff; import flixel.FlxSprite; import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; -using StringTools; - class FreeplayScore extends FlxTypedSpriteGroup { public var scoreShit(default, set):Int = 0; diff --git a/source/funkin/import.hx b/source/funkin/import.hx index 67be91c51..35cd7b869 100644 --- a/source/funkin/import.hx +++ b/source/funkin/import.hx @@ -3,4 +3,11 @@ import funkin.Paths; import flixel.FlxG; // This one in particular causes a compile error if you're using macros. +// These are great. +using Lambda; +using StringTools; + +using funkin.util.tools.IteratorTools; +using funkin.util.tools.StringTools; + #end diff --git a/source/funkin/input/Cursor.hx b/source/funkin/input/Cursor.hx new file mode 100644 index 000000000..50cdad079 --- /dev/null +++ b/source/funkin/input/Cursor.hx @@ -0,0 +1,282 @@ +package funkin.input; + +import openfl.utils.Assets; +import lime.app.Future; +import openfl.display.BitmapData; + +class Cursor +{ + public static var cursorMode(default, set):CursorMode; + + static final CURSOR_DEFAULT_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-default.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorDefault:BitmapData = null; + + static final CURSOR_CROSS_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-cross.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorCross:BitmapData = null; + + static final CURSOR_ERASER_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-eraser.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorEraser:BitmapData = null; + + static final CURSOR_GRABBING_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-grabbing.png", + scale: 1.0, + offsetX: 32, + offsetY: 0, + }; + static var assetCursorGrabbing:BitmapData = null; + + static final CURSOR_HOURGLASS_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-hourglass.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorHourglass:BitmapData = null; + + static final CURSOR_POINTER_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-pointer.png", + scale: 1.0, + offsetX: 8, + offsetY: 0, + }; + static var assetCursorPointer:BitmapData = null; + + static final CURSOR_TEXT_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-text.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorText:BitmapData = null; + + static final CURSOR_ZOOM_IN_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-zoom-in.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorZoomIn:BitmapData = null; + + static final CURSOR_ZOOM_OUT_PARAMS:CursorParams = { + graphic: "assets/images/cursor/cursor-zoom-out.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorZoomOut:BitmapData = null; + + static function set_cursorMode(value:CursorMode):CursorMode + { + if (cursorMode != value) + { + cursorMode = value; + setCursorGraphic(cursorMode); + } + return cursorMode; + } + + public static inline function show():Void + { + FlxG.mouse.visible = true; + } + + public static inline function hide():Void + { + FlxG.mouse.visible = false; + } + + static function setCursorGraphic(?value:CursorMode = null):Void + { + if (value == null) + { + FlxG.mouse.unload(); + return; + } + + switch (value) + { + case Default: + if (assetCursorDefault == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_DEFAULT_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorDefault = bitmapData; + applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS); + } + + case Cross: + if (assetCursorCross == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_CROSS_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorCross = bitmapData; + applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS); + } + + case Eraser: + if (assetCursorEraser == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_ERASER_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorEraser = bitmapData; + applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS); + } + + case Grabbing: + if (assetCursorGrabbing == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_GRABBING_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorGrabbing = bitmapData; + applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS); + } + + case Hourglass: + if (assetCursorHourglass == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_HOURGLASS_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorHourglass = bitmapData; + applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS); + } + + case Pointer: + if (assetCursorPointer == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_POINTER_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorPointer = bitmapData; + applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS); + } + + case Text: + if (assetCursorText == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_TEXT_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorText = bitmapData; + applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS); + } + + case ZoomIn: + if (assetCursorZoomIn == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_ZOOM_IN_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorZoomIn = bitmapData; + applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS); + } + + case ZoomOut: + if (assetCursorZoomOut == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_ZOOM_OUT_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) + { + assetCursorZoomOut = bitmapData; + applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS); + }); + } + else + { + applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS); + } + + default: + setCursorGraphic(null); + } + } + + static inline function applyCursorParams(graphic:BitmapData, params:CursorParams):Void + { + FlxG.mouse.load(graphic, params.scale, params.offsetX, params.offsetY); + } +} + +// https://developer.mozilla.org/en-US/docs/Web/CSS/cursor +enum CursorMode +{ + Default; + Cross; + Eraser; + Grabbing; + Hourglass; + Pointer; + Text; + ZoomIn; + ZoomOut; +} + +/** + * Static data describing how a cursor should be rendered. + */ +typedef CursorParams = +{ + graphic:String, + scale:Float, + offsetX:Int, + offsetY:Int, +} diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index e5b186398..9ec5a968f 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -7,6 +7,7 @@ import funkin.play.stage.StageData; import polymod.Polymod; import polymod.backends.PolymodAssets.PolymodAssetType; import polymod.format.ParseRules.TextFileFormat; +import funkin.util.FileUtil; class PolymodHandler { @@ -25,10 +26,7 @@ class PolymodHandler public static function createModRoot() { - if (!sys.FileSystem.exists(MOD_FOLDER)) - { - sys.FileSystem.createDirectory(MOD_FOLDER); - } + FileUtil.createDirIfNotExists(MOD_FOLDER); } /** @@ -183,7 +181,11 @@ class PolymodHandler public static function getAllMods():Array { trace('Scanning the mods folder...'); - var modMetadata = Polymod.scan(); + var modMetadata = Polymod.scan({ + modRoot: MOD_FOLDER, + apiVersionRule: API_VERSION, + errorCallback: PolymodErrorHandler.onPolymodError + }); trace('Found ${modMetadata.length} mods when scanning.'); return modMetadata; } diff --git a/source/funkin/modding/base/ScriptedFlxRuntimeShader.hx b/source/funkin/modding/base/ScriptedFlxRuntimeShader.hx index 50af136ff..4d17638bb 100644 --- a/source/funkin/modding/base/ScriptedFlxRuntimeShader.hx +++ b/source/funkin/modding/base/ScriptedFlxRuntimeShader.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import flixel.addons.display.FlxRuntimeShader; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedFlxRuntimeShader extends FlxRuntimeShader implements HScriptedClass {} +class ScriptedFlxRuntimeShader extends flixel.addons.display.FlxRuntimeShader implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/ScriptedFlxSprite.hx b/source/funkin/modding/base/ScriptedFlxSprite.hx index e4ae69107..036f16c00 100644 --- a/source/funkin/modding/base/ScriptedFlxSprite.hx +++ b/source/funkin/modding/base/ScriptedFlxSprite.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import flixel.FlxSprite; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedFlxSprite extends FlxSprite implements HScriptedClass {} +class ScriptedFlxSprite extends flixel.FlxSprite implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx index 7d35aab47..049c2b668 100644 --- a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx +++ b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import flixel.group.FlxSpriteGroup; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedFlxSpriteGroup extends FlxSpriteGroup implements HScriptedClass {} +class ScriptedFlxSpriteGroup extends flixel.group.FlxSpriteGroup implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/ScriptedFlxState.hx b/source/funkin/modding/base/ScriptedFlxState.hx index 2a498e66b..4069a33eb 100644 --- a/source/funkin/modding/base/ScriptedFlxState.hx +++ b/source/funkin/modding/base/ScriptedFlxState.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import flixel.FlxState; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedFlxState extends FlxState implements HScriptedClass {} +class ScriptedFlxState extends flixel.FlxState implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/ScriptedFlxSubState.hx b/source/funkin/modding/base/ScriptedFlxSubState.hx index 90c2a6474..e5b85b26d 100644 --- a/source/funkin/modding/base/ScriptedFlxSubState.hx +++ b/source/funkin/modding/base/ScriptedFlxSubState.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import flixel.FlxSubState; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedFlxSubState extends FlxSubState implements HScriptedClass {} +class ScriptedFlxSubState extends flixel.FlxSubState implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/ScriptedFlxTransitionableState.hx b/source/funkin/modding/base/ScriptedFlxTransitionableState.hx index 1d2b92b27..4bc310222 100644 --- a/source/funkin/modding/base/ScriptedFlxTransitionableState.hx +++ b/source/funkin/modding/base/ScriptedFlxTransitionableState.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import flixel.addons.transition.FlxTransitionableState; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedFlxTransitionableState extends FlxTransitionableState implements HScriptedClass {} +class ScriptedFlxTransitionableState extends flixel.addons.transition.FlxTransitionableState implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/ScriptedFlxUIState.hx b/source/funkin/modding/base/ScriptedFlxUIState.hx index 1799be32a..4260850ad 100644 --- a/source/funkin/modding/base/ScriptedFlxUIState.hx +++ b/source/funkin/modding/base/ScriptedFlxUIState.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import flixel.addons.ui.FlxUIState; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedFlxUIState extends FlxUIState implements HScriptedClass {} +class ScriptedFlxUIState extends flixel.addons.ui.FlxUIState implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/ScriptedMusicBeatState.hx b/source/funkin/modding/base/ScriptedMusicBeatState.hx index 430022029..236df3911 100644 --- a/source/funkin/modding/base/ScriptedMusicBeatState.hx +++ b/source/funkin/modding/base/ScriptedMusicBeatState.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import funkin.MusicBeatState; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedMusicBeatState extends MusicBeatState implements HScriptedClass {} +class ScriptedMusicBeatState extends funkin.MusicBeatState implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/ScriptedMusicBeatSubstate.hx b/source/funkin/modding/base/ScriptedMusicBeatSubstate.hx index dd399a74c..c56776ea8 100644 --- a/source/funkin/modding/base/ScriptedMusicBeatSubstate.hx +++ b/source/funkin/modding/base/ScriptedMusicBeatSubstate.hx @@ -1,7 +1,6 @@ package funkin.modding.base; -import funkin.MusicBeatSubstate; -import polymod.hscript.HScriptedClass; - @:hscriptClass -class ScriptedMusicBeatSubstate extends MusicBeatSubstate implements HScriptedClass {} +class ScriptedMusicBeatSubstate extends funkin.MusicBeatSubstate implements HScriptedClass +{ +} diff --git a/source/funkin/modding/base/import.hx b/source/funkin/modding/base/import.hx new file mode 100644 index 000000000..957fb894c --- /dev/null +++ b/source/funkin/modding/base/import.hx @@ -0,0 +1 @@ +import polymod.hscript.HScriptedClass; diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx index 4617fa35b..93ac7bc66 100644 --- a/source/funkin/modding/module/ModuleHandler.hx +++ b/source/funkin/modding/module/ModuleHandler.hx @@ -6,8 +6,6 @@ import funkin.modding.events.ScriptEventDispatcher; import funkin.modding.module.Module; import funkin.modding.module.ScriptedModule; -using funkin.util.IteratorTools; - /** * Utility functions for loading and manipulating active modules. */ diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx index 235db587e..5d722bbef 100644 --- a/source/funkin/play/Countdown.hx +++ b/source/funkin/play/Countdown.hx @@ -10,8 +10,6 @@ import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEvent.CountdownScriptEvent; import flixel.util.FlxTimer; -using StringTools; - class Countdown { /** diff --git a/source/funkin/play/GameOverSubstate.hx b/source/funkin/play/GameOverSubstate.hx index 48a65df0a..0cf5f7379 100644 --- a/source/funkin/play/GameOverSubstate.hx +++ b/source/funkin/play/GameOverSubstate.hx @@ -11,8 +11,6 @@ import funkin.play.PlayState; import funkin.play.character.BaseCharacter; import funkin.ui.PreferencesMenu; -using StringTools; - /** * A substate which renders over the PlayState when the player dies. * Displays the player death animation, plays the music, and handles restarting the song. diff --git a/source/funkin/play/HealthIcon.hx b/source/funkin/play/HealthIcon.hx index 4c4d8caaa..d86788e2b 100644 --- a/source/funkin/play/HealthIcon.hx +++ b/source/funkin/play/HealthIcon.hx @@ -196,17 +196,11 @@ class HealthIcon extends FlxSprite // Make the health icons bump (the update function causes them to lerp back down). if (this.width > this.height) { - var targetSize = Std.int(CoolUtil.coolLerp(this.width + HEALTH_ICON_SIZE * 0.2, HEALTH_ICON_SIZE, 0.15)); - targetSize = Std.int(Math.min(targetSize, HEALTH_ICON_SIZE * 1.2)); - - setGraphicSize(targetSize, 0); + setGraphicSize(Std.int(this.width + (HEALTH_ICON_SIZE * this.size.x * 0.2)), 0); } else { - var targetSize = Std.int(CoolUtil.coolLerp(this.height + HEALTH_ICON_SIZE * 0.2, HEALTH_ICON_SIZE, 0.15)); - targetSize = Std.int(Math.min(targetSize, HEALTH_ICON_SIZE * 1.2)); - - setGraphicSize(0, targetSize); + setGraphicSize(0, Std.int(this.height + (HEALTH_ICON_SIZE * this.size.y * 0.2))); } this.updateHitbox(); } diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index c625437f6..f5fff3cf5 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -43,9 +43,6 @@ import funkin.ui.stageBuildShit.StageOffsetSubstate; import funkin.util.Constants; import funkin.util.SortUtil; import lime.ui.Haptic; - -using StringTools; - #if discord_rpc import Discord.DiscordClient; #end @@ -356,7 +353,7 @@ class PlayState extends MusicBeatState if (currentSong_NEW != null) { - Conductor.mapTimeChanges(currentChart); + Conductor.mapTimeChanges(currentChart.timeChanges); // Conductor.bpm = currentChart.getStartingBPM(); // TODO: Support for dialog. @@ -1032,9 +1029,9 @@ class PlayState extends MusicBeatState currentSong.song = currentSong.song; if (currentSong.needsVoices) - vocals = new VoicesGroup(currentSong.song, currentSong.voiceList); + vocals = VoicesGroup.build(currentSong.song, currentSong.voiceList); else - vocals = new VoicesGroup(currentSong.song, null); + vocals = VoicesGroup.build(currentSong.song, null); vocals.members[0].onComplete = function() { diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx index 65cb18461..30dabe0cf 100644 --- a/source/funkin/play/character/BaseCharacter.hx +++ b/source/funkin/play/character/BaseCharacter.hx @@ -6,8 +6,6 @@ import funkin.noteStuff.NoteBasic.NoteDir; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.stage.Bopper; -using StringTools; - /** * A Character is a stage prop which bops to the music as well as controlled by the strumlines. * @@ -96,8 +94,9 @@ class BaseCharacter extends Bopper if (animOffsets == value) return value; - var xDiff = animOffsets[0] - value[0]; - var yDiff = animOffsets[1] - value[1]; + // Make sure animOffets are halved when scale is 0.5. + var xDiff = (animOffsets[0] * this.scale.x) - value[0]; + var yDiff = (animOffsets[1] * this.scale.y) - value[1]; // Call the super function so that camera focus point is not affected. super.set_x(this.x + xDiff); diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx index 369adb00c..2eaf4f944 100644 --- a/source/funkin/play/character/CharacterData.hx +++ b/source/funkin/play/character/CharacterData.hx @@ -16,8 +16,6 @@ import funkin.util.assets.DataAssets; import haxe.Json; import openfl.utils.Assets; -using StringTools; - class CharacterDataParser { /** @@ -225,7 +223,7 @@ class CharacterDataParser public static function listCharacterIds():Array { - return [for (x in characterCache.keys()) x]; + return characterCache.keys().array(); } static function clearCharacterCache():Void @@ -258,7 +256,7 @@ class CharacterDataParser static function loadCharacterFile(charPath:String):String { var charFilePath:String = Paths.json('characters/${charPath}'); - var rawJson = StringTools.trim(Assets.getText(charFilePath)); + var rawJson = Assets.getText(charFilePath).trim(); while (!StringTools.endsWith(rawJson, "}")) { diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index b8ef2d7d6..08ce6818f 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -45,6 +45,11 @@ class Song // implements IPlayStateScriptedClass populateFromMetadata(); } + public function getRawMetadata():Array + { + return _metadata; + } + /** * Populate the song data from the provided metadata, * including data from individual difficulties. Does not load chart data. @@ -122,8 +127,11 @@ class Song // implements IPlayStateScriptedClass /** * Retrieve the metadata for a specific difficulty, including the chart if it is loaded. */ - public inline function getDifficulty(diffId:String):SongDifficulty + public inline function getDifficulty(?diffId:String):SongDifficulty { + if (diffId == null) + diffId = difficulties.keys().array()[0]; + return difficulties.get(diffId); } @@ -214,7 +222,7 @@ class SongDifficulty public function getPlayableChars():Array { - return [for (i in chars.keys()) i]; + return chars.keys().array(); } public function getEvents():Array @@ -246,7 +254,7 @@ class SongDifficulty public function buildVocals(charId:String = "bf"):VoicesGroup { - var result:VoicesGroup = new VoicesGroup(this.song.songId, this.buildVoiceList()); + 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 e5679cab9..775e78c11 100644 --- a/source/funkin/play/song/SongData.hx +++ b/source/funkin/play/song/SongData.hx @@ -8,8 +8,6 @@ import haxe.Json; import openfl.utils.Assets; import thx.semver.Version; -using StringTools; - /** * Contains utilities for loading and parsing stage data. */ @@ -96,12 +94,12 @@ class SongDataParser if (songCache.exists(songId)) { var song:Song = songCache.get(songId); - trace('[STAGEDATA] Successfully fetch song: ${songId}'); + trace('[SONGDATA] Successfully fetch song: ${songId}'); return song; } else { - trace('[STAGEDATA] Failed to fetch song, not found in cache: ${songId}'); + trace('[SONGDATA] Failed to fetch song, not found in cache: ${songId}'); return null; } } @@ -116,7 +114,7 @@ class SongDataParser public static function listSongIds():Array { - return [for (x in songCache.keys()) x]; + return songCache.keys().array(); } public static function parseSongMetadata(songId:String):Array @@ -261,9 +259,25 @@ abstract SongMetadata(RawSongMetadata) noteSkin: 'Normal' }, generatedBy: SongValidator.DEFAULT_GENERATEDBY, + + // 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; + + return result; + } } typedef SongPlayData = diff --git a/source/funkin/play/song/SongDataUtils.hx b/source/funkin/play/song/SongDataUtils.hx index 8304e8014..0c03c1e36 100644 --- a/source/funkin/play/song/SongDataUtils.hx +++ b/source/funkin/play/song/SongDataUtils.hx @@ -135,7 +135,7 @@ class SongDataUtils trace('Read ' + notesString.length + ' characters from clipboard.'); - var notes:Array = SerializerUtil.fromJSON(notesString); + var notes:Array = notesString.parseJSON(); if (notes == null) { diff --git a/source/funkin/play/song/SongSerializer.hx b/source/funkin/play/song/SongSerializer.hx index af00106d0..31e37415f 100644 --- a/source/funkin/play/song/SongSerializer.hx +++ b/source/funkin/play/song/SongSerializer.hx @@ -25,7 +25,7 @@ class SongSerializer if (fileData == null) return null; - var songChartData:SongChartData = SerializerUtil.fromJSON(fileData); + var songChartData:SongChartData = fileData.parseJSON(); return songChartData; } @@ -41,7 +41,7 @@ class SongSerializer if (fileData == null) return null; - var songMetadata:SongMetadata = SerializerUtil.fromJSON(fileData); + var songMetadata:SongMetadata = fileData.parseJSON(); return songMetadata; } @@ -59,7 +59,7 @@ class SongSerializer if (data == null) return; - var songChartData:SongChartData = SerializerUtil.fromJSON(data); + var songChartData:SongChartData = data.parseJSON(); if (songChartData != null) callback(songChartData); @@ -79,7 +79,7 @@ class SongSerializer if (data == null) return; - var songMetadata:SongMetadata = SerializerUtil.fromJSON(data); + var songMetadata:SongMetadata = data.parseJSON(); if (songMetadata != null) callback(songMetadata); diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index 2fe42bffb..39e46bccf 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -6,6 +6,9 @@ import flixel.util.FlxTimer; import funkin.modding.IScriptedClass.IPlayStateScriptedClass; import funkin.modding.events.ScriptEvent; +typedef AnimationFrameCallback = String->Int->Int->Void; +typedef AnimationFinishedCallback = String->Void; + /** * A Bopper is a stage prop which plays a dance animation. * Y'know, a thingie that bops. A bopper. @@ -68,8 +71,8 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass this.x += xDiff; this.y += yDiff; - return animOffsets = value; + } private var animOffsets(default, set):Array = [0, 0]; @@ -113,6 +116,7 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass */ function onAnimationFinished(name:String) { + // TODO: Can we make a system of like, animation priority or something? if (!canPlayOtherAnims) { canPlayOtherAnims = true; @@ -131,10 +135,10 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1) { // Do nothing by default. - // This can be overridden by, for example, scripted characters. + // 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. - - // Sometimes this gets called with empty values? IDK why but adding defaults keeps it from crashing. } /** diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx index 0ecc5043b..380efc2fa 100644 --- a/source/funkin/play/stage/StageData.hx +++ b/source/funkin/play/stage/StageData.hx @@ -8,8 +8,6 @@ import funkin.util.assets.DataAssets; import haxe.Json; import openfl.Assets; -using StringTools; - /** * Contains utilities for loading and parsing stage data. */ @@ -143,7 +141,7 @@ class StageDataParser public static function listStageIds():Array { - return [for (x in stageCache.keys()) x]; + return stageCache.keys().array(); } static function loadStageFile(stagePath:String):String diff --git a/source/funkin/ui/PopUpStuff.hx b/source/funkin/ui/PopUpStuff.hx index b724d7c3f..8893e1fcd 100644 --- a/source/funkin/ui/PopUpStuff.hx +++ b/source/funkin/ui/PopUpStuff.hx @@ -6,8 +6,6 @@ import flixel.tweens.FlxTween; import funkin.play.PlayState; import funkin.util.Constants; -using StringTools; - class PopUpStuff extends FlxTypedGroup { override public function new() diff --git a/source/funkin/ui/animDebugShit/DebugBoundingState.hx b/source/funkin/ui/animDebugShit/DebugBoundingState.hx index cf28d23da..af84ed117 100644 --- a/source/funkin/ui/animDebugShit/DebugBoundingState.hx +++ b/source/funkin/ui/animDebugShit/DebugBoundingState.hx @@ -33,7 +33,6 @@ import openfl.net.URLLoader; import openfl.net.URLRequest; import openfl.utils.ByteArray; -using StringTools; using flixel.util.FlxSpriteUtil; #if web diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx index 39cae449b..64fab3866 100644 --- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx +++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx @@ -64,6 +64,7 @@ class AddNotesCommand implements ChartEditorCommand state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -76,6 +77,7 @@ class AddNotesCommand implements ChartEditorCommand state.currentSelection = []; state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -109,6 +111,7 @@ class RemoveNotesCommand implements ChartEditorCommand state.currentSelection = []; state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -124,6 +127,7 @@ class RemoveNotesCommand implements ChartEditorCommand state.currentSelection = notes; state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -409,6 +413,7 @@ class CutNotesCommand implements ChartEditorCommand // Delete the notes. state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); state.currentSelection = []; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; state.sortChartData(); @@ -419,6 +424,7 @@ class CutNotesCommand implements ChartEditorCommand state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); state.currentSelection = notes; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -453,6 +459,7 @@ class FlipNotesCommand implements ChartEditorCommand state.currentSelection = flippedNotes; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; state.sortChartData(); @@ -465,6 +472,7 @@ class FlipNotesCommand implements ChartEditorCommand state.currentSelection = notes; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -498,6 +506,7 @@ class PasteNotesCommand implements ChartEditorCommand state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes); state.currentSelection = addedNotes.copy(); + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -509,6 +518,7 @@ class PasteNotesCommand implements ChartEditorCommand state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes); state.currentSelection = []; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -539,6 +549,7 @@ class AddEventsCommand implements ChartEditorCommand // TODO: Allow selecting events. // state.currentSelection = events; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -551,6 +562,7 @@ class AddEventsCommand implements ChartEditorCommand state.currentSelection = []; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -581,6 +593,7 @@ class ExtendNoteLengthCommand implements ChartEditorCommand { note.length = newLength; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; @@ -591,6 +604,7 @@ class ExtendNoteLengthCommand implements ChartEditorCommand { note.length = oldLength; + state.saveDataDirty = true; state.noteDisplayDirty = true; state.notePreviewDirty = true; diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx index e118fff8e..0b49dd201 100644 --- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx @@ -1,5 +1,497 @@ package funkin.ui.debug.charting; +import flixel.FlxSprite; +import flixel.util.FlxTimer; +import funkin.input.Cursor; +import funkin.play.character.BaseCharacter; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.song.SongData.SongDataParser; +import funkin.play.song.SongData.SongPlayableChar; +import funkin.play.song.SongData.SongTimeChange; +import haxe.ui.components.Button; +import haxe.ui.components.DropDown; +import haxe.ui.components.Image; +import haxe.ui.components.Label; +import haxe.ui.components.Link; +import haxe.ui.components.NumberStepper; +import haxe.ui.components.TextField; +import haxe.ui.containers.Box; +import haxe.ui.containers.dialogs.Dialog; +import haxe.ui.containers.dialogs.Dialogs; +import haxe.ui.containers.properties.Property; +import haxe.ui.containers.properties.PropertyGrid; +import haxe.ui.containers.properties.PropertyGroup; +import haxe.ui.containers.VBox; +import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; + +using Lambda; + class ChartEditorDialogHandler { + static final CHART_EDITOR_DIALOG_ABOUT_LAYOUT = Paths.ui('chart-editor/dialogs/about'); + static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT = Paths.ui('chart-editor/dialogs/welcome'); + static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT = Paths.ui('chart-editor/dialogs/upload-inst'); + static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT = Paths.ui('chart-editor/dialogs/song-metadata'); + static final CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT = Paths.ui('chart-editor/dialogs/song-metadata-chargroup'); + static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT = Paths.ui('chart-editor/dialogs/upload-vocals'); + static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT = Paths.ui('chart-editor/dialogs/upload-vocals-entry'); + static final CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT = Paths.ui('chart-editor/dialogs/user-guide'); + + /** + * + */ + public static inline function openAboutDialog(state:ChartEditorState):Dialog + { + return openDialog(state, CHART_EDITOR_DIALOG_ABOUT_LAYOUT, true, true); + } + + /** + * Builds and opens a dialog letting the user create a new chart, open a recent chart, or load from a template. + */ + public static function openWelcomeDialog(state:ChartEditorState, closable:Bool = true):Dialog + { + var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable); + + // TODO: Add callbacks to the dialog buttons + + // Switch the graphic for frames. + var bfSpritePlaceholder:Image = dialog.findComponent('bfSprite', Image); + + // TODO: Replace this bullshit with a custom HaxeUI component that loads the sprite from the game's assets. + + if (bfSpritePlaceholder != null) + { + var bfSprite:FlxSprite = new FlxSprite(0, 0); + + bfSprite.visible = false; + + var frames = Paths.getSparrowAtlas(bfSpritePlaceholder.resource); + bfSprite.frames = frames; + + bfSprite.animation.addByPrefix('idle', 'Boyfriend DJ0', 24, true); + bfSprite.animation.play('idle'); + + bfSpritePlaceholder.rootComponent.add(bfSprite); + bfSpritePlaceholder.visible = false; + + new FlxTimer().start(0.10, (_timer:FlxTimer) -> + { + bfSprite.x = bfSpritePlaceholder.screenLeft; + bfSprite.y = bfSpritePlaceholder.screenTop; + bfSprite.setGraphicSize(Std.int(bfSpritePlaceholder.width), Std.int(bfSpritePlaceholder.height)); + bfSprite.visible = true; + }); + } + + // Add handlers to the "Create From Song" section. + var linkCreateBasic:Link = dialog.findComponent('splashCreateFromSongBasic', Link); + linkCreateBasic.onClick = (_event) -> + { + dialog.hideDialog(DialogButton.CANCEL); + + // Create song wizard + var uploadInstDialog = openUploadInstDialog(state, false); + uploadInstDialog.onDialogClosed = (_event) -> + { + state.isHaxeUIDialogOpen = false; + if (_event.button == DialogButton.APPLY) + { + var songMetadataDialog = openSongMetadataDialog(state); + songMetadataDialog.onDialogClosed = (_event) -> + { + state.isHaxeUIDialogOpen = false; + if (_event.button == DialogButton.APPLY) + { + var uploadVocalsDialog = openUploadVocalsDialog(state); + } + }; + } + }; + } + + // TODO: Get the list of songs and insert them as links into the "Create From Song" section. + + /* + var linkTemplateDadBattle:Link = dialog.findComponent('splashTemplateDadBattle', Link); + linkTemplateDadBattle.onClick = (_event) -> + { + dialog.hideDialog(DialogButton.CANCEL); + + // Load song from template + state.loadSongAsTemplate('dadbattle'); + } + var linkTemplateBopeebo:Link = dialog.findComponent('splashTemplateBopeebo', Link); + linkTemplateBopeebo.onClick = (_event) -> + { + dialog.hideDialog(DialogButton.CANCEL); + + // Load song from template + state.loadSongAsTemplate('bopeebo'); + } + */ + + var splashTemplateContainer:VBox = dialog.findComponent('splashTemplateContainer', VBox); + + var songList:Array = SongDataParser.listSongIds(); + + for (targetSongId in songList) { + var songData = SongDataParser.fetchSong(targetSongId); + + if (songData == null) + continue; + + var songName = songData.getDifficulty().songName; + + var linkTemplateSong:Link = new Link(); + linkTemplateSong.text = songName; + linkTemplateSong.onClick = (_event) -> + { + dialog.hideDialog(DialogButton.CANCEL); + + // Load song from template + state.loadSongAsTemplate(targetSongId); + } + + splashTemplateContainer.addComponent(linkTemplateSong); + } + + return dialog; + } + + public static function openUploadInstDialog(state:ChartEditorState, ?closable:Bool = true):Dialog + { + var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT, true, closable); + + var instrumentalBox:Box = dialog.findComponent('instrumentalBox', Box); + + instrumentalBox.onMouseOver = (_event) -> + { + instrumentalBox.swapClass('upload-bg', 'upload-bg-hover'); + Cursor.cursorMode = Pointer; + } + + instrumentalBox.onMouseOut = (_event) -> + { + instrumentalBox.swapClass('upload-bg-hover', 'upload-bg'); + Cursor.cursorMode = Default; + } + + var onDropFile:String->Void; + + instrumentalBox.onClick = (_event) -> + { + Dialogs.openBinaryFile("Open Instrumental", [{label: "Audio File (.ogg)", extension: "ogg"}], function(selectedFile) + { + if (selectedFile != null) + { + trace('Selected file: ' + selectedFile); + state.loadInstrumentalFromBytes(selectedFile.bytes); + dialog.hideDialog(DialogButton.APPLY); + removeDropHandler(onDropFile); + } + }); + } + + onDropFile = (path:String) -> + { + trace('Dropped file: ' + path); + state.loadInstrumentalFromPath(path); + dialog.hideDialog(DialogButton.APPLY); + removeDropHandler(onDropFile); + }; + + addDropHandler(onDropFile); + + return dialog; + } + + static function addDropHandler(handler:String->Void) + { + #if desktop + FlxG.stage.window.onDropFile.add(handler); + #else + trace('addDropHandler not implemented for this platform'); + #end + } + + static function removeDropHandler(handler:String->Void) + { + #if desktop + FlxG.stage.window.onDropFile.remove(handler); + #end + } + + public static function openSongMetadataDialog(state:ChartEditorState):Dialog + { + var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false); + + var dialogSongName:TextField = dialog.findComponent('dialogSongName', TextField); + dialogSongName.onChange = (event:UIEvent) -> + { + var valid = event.target.text != null && event.target.text != ""; + + if (valid) + { + dialogSongName.removeClass('invalid-value'); + state.currentSongMetadata.songName = event.target.text; + } + else + { + state.currentSongMetadata.songName = null; + } + }; + state.currentSongMetadata.songName = null; + + var dialogSongArtist:TextField = dialog.findComponent('dialogSongArtist', TextField); + dialogSongArtist.onChange = (event:UIEvent) -> + { + var valid = event.target.text != null && event.target.text != ""; + + if (valid) + { + dialogSongArtist.removeClass('invalid-value'); + state.currentSongMetadata.artist = event.target.text; + } + else + { + state.currentSongMetadata.artist = null; + } + }; + state.currentSongMetadata.artist = null; + + var dialogStage:DropDown = dialog.findComponent('dialogStage', DropDown); + dialogStage.onChange = (event:UIEvent) -> + { + var valid = event.data != null && event.data.id != null; + + if (event.data.id == null) + return; + state.currentSongMetadata.playData.stage = event.data.id; + }; + state.currentSongMetadata.playData.stage = null; + + var dialogNoteSkin:DropDown = dialog.findComponent('dialogNoteSkin', DropDown); + dialogNoteSkin.onChange = (event:UIEvent) -> + { + if (event.data.id == null) + return; + state.currentSongMetadata.playData.noteSkin = event.data.id; + }; + state.currentSongMetadata.playData.noteSkin = null; + + var dialogBPM:NumberStepper = dialog.findComponent('dialogBPM', NumberStepper); + dialogBPM.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 dialogCharGrid:PropertyGrid = dialog.findComponent('dialogCharGrid', PropertyGrid); + var dialogCharAdd:Button = dialog.findComponent('dialogCharAdd', Button); + dialogCharAdd.onClick = (_event) -> + { + var charGroup:PropertyGroup; + charGroup = buildCharGroup(state, null, () -> + { + dialogCharGrid.removeComponent(charGroup); + }); + dialogCharGrid.addComponent(charGroup); + }; + + // Empty the character list. + state.currentSongMetadata.playData.playableChars = {}; + // Add at least one character group with no Remove button. + dialogCharGrid.addComponent(buildCharGroup(state, 'bf', null)); + + var dialogContinue:Button = dialog.findComponent('dialogContinue', Button); + dialogContinue.onClick = (_event) -> + { + dialog.hideDialog(DialogButton.APPLY); + }; + + return dialog; + } + + static function buildCharGroup(state:ChartEditorState, ?key:String = null, removeFunc:Void->Void):PropertyGroup + { + var groupKey = key; + + var getCharData = () -> + { + if (groupKey == null) + groupKey = 'newChar${state.currentSongMetadata.playData.playableChars.keys().count()}'; + + var result = state.currentSongMetadata.playData.playableChars.get(groupKey); + if (result == null) + { + result = new SongPlayableChar('', 'dad'); + state.currentSongMetadata.playData.playableChars.set(groupKey, result); + } + return result; + } + + var moveCharGroup = (target:String) -> + { + var charData = getCharData(); + state.currentSongMetadata.playData.playableChars.remove(groupKey); + state.currentSongMetadata.playData.playableChars.set(target, charData); + groupKey = target; + } + + var removeGroup = () -> + { + state.currentSongMetadata.playData.playableChars.remove(groupKey); + removeFunc(); + } + + var charData = getCharData(); + + var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT); + + var charGroupPlayer:DropDown = charGroup.findComponent('charGroupPlayer', DropDown); + charGroupPlayer.onChange = (event:UIEvent) -> + { + charGroup.text = event.data.text; + moveCharGroup(event.data.id); + }; + + if (key == null) + { + // Find the next available player character. + trace(charGroupPlayer.dataSource.data); + } + + var charGroupOpponent:DropDown = charGroup.findComponent('charGroupOpponent', DropDown); + charGroupOpponent.onChange = (event:UIEvent) -> + { + charData.opponent = event.data.id; + }; + charGroupOpponent.value = getCharData().opponent; + + var charGroupGirlfriend:DropDown = charGroup.findComponent('charGroupGirlfriend', DropDown); + charGroupGirlfriend.onChange = (event:UIEvent) -> + { + charData.girlfriend = event.data.id; + }; + charGroupGirlfriend.value = getCharData().girlfriend; + + var charGroupRemove:Button = charGroup.findComponent('charGroupRemove', Button); + charGroupRemove.onClick = (_event:MouseEvent) -> + { + removeGroup(); + }; + + if (removeFunc == null) + charGroupRemove.hidden = true; + + return charGroup; + } + + public static function openUploadVocalsDialog(state:ChartEditorState, ?closable:Bool = true):Dialog + { + var charIdsForVocals = []; + + for (charKey in state.currentSongMetadata.playData.playableChars.keys()) + { + var charData = state.currentSongMetadata.playData.playableChars.get(charKey); + charIdsForVocals.push(charKey); + if (charData.opponent != null) + charIdsForVocals.push(charData.opponent); + } + + var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable); + + var dialogContainer = dialog.findComponent('vocalContainer'); + + var onDropFile:String->Void; + + for (charKey in charIdsForVocals) + { + trace('Adding vocal upload for character ${charKey}'); + var charMetadata:BaseCharacter = CharacterDataParser.fetchCharacter(charKey); + var charName:String = charMetadata.characterName; + + var vocalsEntry = state.buildComponent(CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT); + + var vocalsEntryLabel:Label = vocalsEntry.findComponent('vocalsEntryLabel', Label); + vocalsEntryLabel.text = 'Click to browse for a vocal track for $charName.'; + + vocalsEntry.onClick = (_event) -> + { + Dialogs.openBinaryFile('Open $charName Vocals', [{label: "Audio File (.ogg)", extension: "ogg"}], function(selectedFile) + { + if (selectedFile != null) + { + trace('Selected file: ' + selectedFile.name + "~" + selectedFile.fullPath); + vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}'; + state.loadVocalsFromBytes(selectedFile.bytes); + removeDropHandler(onDropFile); + } + }); + } + + dialogContainer.addComponent(vocalsEntry); + } + + var dialogContinue:Button = dialog.findComponent('dialogContinue', Button); + dialogContinue.onClick = (_event) -> + { + dialog.hideDialog(DialogButton.APPLY); + }; + + // TODO: Redo the logic for file drop handler to be more robust. + // We need to distinguish which component the mouse is over when the file is dropped. + + onDropFile = (path:String) -> + { + trace('Dropped file: ' + path); + }; + addDropHandler(onDropFile); + + return dialog; + } + + /** + * Builds and opens a dialog displaying the user guide, providing guidance and help on how to use the chart editor. + */ + public static inline function openUserGuideDialog(state:ChartEditorState):Dialog + { + return openDialog(state, CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT, true, true); + } + + /** + * Builds and opens a dialog from a given layout path. + * @param modal Makes the background uninteractable while the dialog is open. + * @param closable Hides the close button on the dialog, preventing it from being closed unless the user interacts with the dialog. + */ + static function openDialog(state:ChartEditorState, key:String, modal:Bool = true, closable:Bool = true):Dialog + { + var dialog:Dialog = cast state.buildComponent(key); + dialog.destroyOnClose = true; + dialog.closable = closable; + dialog.showDialog(modal); + + state.isHaxeUIDialogOpen = true; + dialog.onDialogClosed = (_event) -> + { + state.isHaxeUIDialogOpen = false; + }; + + return dialog; + } } diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx index ab0c29f15..cf1a2e018 100644 --- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx +++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx @@ -1,5 +1,7 @@ package funkin.ui.debug.charting; +import flixel.FlxObject; +import flixel.FlxBasic; import flixel.FlxSprite; import flixel.graphics.frames.FlxFramesCollection; import flixel.graphics.frames.FlxTileFrames; @@ -12,17 +14,14 @@ import funkin.play.song.SongData.SongNoteData; */ class ChartEditorNoteSprite 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 noteData(default, set):SongNoteData; - /** - * The note skin that this sprite displays. - */ - public var noteSkin(default, set):String = 'Normal'; - /** * This note is the previous sprite in a sustain chain. */ @@ -33,10 +32,12 @@ class ChartEditorNoteSprite extends FlxSprite */ public var childNoteSprite(default, set):ChartEditorNoteSprite = null; - public function new() + public function new(parent:ChartEditorState) { super(); + this.parentState = parent; + if (noteFrameCollection == null) { initFrameCollection(); @@ -131,26 +132,12 @@ class ChartEditorNoteSprite extends FlxSprite playNoteAnimation(); // Update the position to match the note data. - setNotePosition(); + updateNotePosition(); return this.noteData; } - function set_noteSkin(value:String):String - { - // Don't update if the skin hasn't changed. - if (value == this.noteSkin) - return this.noteSkin; - - this.noteSkin = value; - - // Make sure to update the graphic to match the note skin. - playNoteAnimation(); - - return this.noteSkin; - } - - function setNotePosition() + public function updateNotePosition(?origin:FlxObject) { var cursorColumn:Int = this.noteData.data; @@ -179,7 +166,13 @@ class ChartEditorNoteSprite extends FlxSprite // Notes far in the song will start far down, but the group they belong to will have a high negative offset. // TODO: stepTime doesn't account for fluctuating BPMs. - this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE; + if (this.noteData.stepTime >= 0) + this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE; + + if (origin != null) { + this.x += origin.x; + this.y += origin.y; + } } else { @@ -214,7 +207,6 @@ class ChartEditorNoteSprite extends FlxSprite if (this.parentNoteSprite != null) { this.noteData = this.parentNoteSprite.noteData; - this.noteSkin = this.parentNoteSprite.noteSkin; } return this.parentNoteSprite; @@ -227,13 +219,12 @@ class ChartEditorNoteSprite extends FlxSprite if (this.parentNoteSprite != null) { this.noteData = this.parentNoteSprite.noteData; - this.noteSkin = this.parentNoteSprite.noteSkin; } return this.childNoteSprite; } - function playNoteAnimation() + public function playNoteAnimation() { // Decide whether to display a note or a sustain. var baseAnimationName:String = 'tap'; @@ -241,7 +232,7 @@ class ChartEditorNoteSprite extends FlxSprite baseAnimationName = (this.childNoteSprite != null) ? 'hold' : 'holdEnd'; // Play the appropriate animation for the type, direction, and skin. - var animationName = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteSkin}'; + var animationName = '${baseAnimationName}${this.noteData.getDirectionName()}${this.parentState.currentSongNoteSkin}'; this.animation.play(animationName); @@ -266,7 +257,7 @@ class ChartEditorNoteSprite extends FlxSprite this.updateHitbox(); // TODO: Make this an attribute of the note skin. - this.antialiasing = (noteSkin != 'Pixel'); + this.antialiasing = (this.parentState.currentSongNoteSkin != 'Pixel'); } /** diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 6e5bc10d1..9acaf59f0 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -1,8 +1,9 @@ package funkin.ui.debug.charting; -import flixel.FlxSprite; +import haxe.io.Path; import flixel.addons.display.FlxSliceSprite; import flixel.addons.display.FlxTiledSprite; +import flixel.FlxSprite; import flixel.group.FlxSpriteGroup; import flixel.math.FlxPoint; import flixel.math.FlxRect; @@ -11,8 +12,14 @@ import flixel.util.FlxColor; import flixel.util.FlxSort; import flixel.util.FlxTimer; import funkin.audio.visualize.PolygonSpectogram; +import funkin.audio.VocalGroup; +import funkin.input.Cursor; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; import funkin.play.HealthIcon; +import funkin.play.song.Song; import funkin.play.song.SongData.SongChartData; +import funkin.play.song.SongData.SongDataParser; import funkin.play.song.SongData.SongEventData; import funkin.play.song.SongData.SongMetadata; import funkin.play.song.SongData.SongNoteData; @@ -21,29 +28,34 @@ import funkin.play.song.SongSerializer; import funkin.ui.debug.charting.ChartEditorCommand; import funkin.ui.debug.charting.ChartEditorThemeHandler.ChartEditorTheme; import funkin.ui.debug.charting.ChartEditorToolboxHandler.ChartEditorToolMode; +import funkin.ui.haxeui.components.CharacterPlayer; import funkin.ui.haxeui.HaxeUIState; import funkin.util.Constants; +import funkin.util.FileUtil; +import funkin.util.DateUtil; +import funkin.util.SerializerUtil; import haxe.ui.components.Button; import haxe.ui.components.CheckBox; import haxe.ui.components.Label; import haxe.ui.components.Slider; -import haxe.ui.containers.SideBar; -import haxe.ui.containers.TreeView; -import haxe.ui.containers.TreeViewNode; import haxe.ui.containers.dialogs.Dialog; import haxe.ui.containers.dialogs.MessageBox; import haxe.ui.containers.menus.MenuCheckBox; import haxe.ui.containers.menus.MenuItem; +import haxe.ui.containers.SideBar; +import haxe.ui.containers.TreeView; +import haxe.ui.containers.TreeViewNode; import haxe.ui.core.Component; import haxe.ui.core.Screen; import haxe.ui.events.DragEvent; import haxe.ui.events.MouseEvent; import haxe.ui.events.UIEvent; +import lime.media.AudioBuffer; +import funkin.util.WindowUtil; import openfl.display.BitmapData; import openfl.geom.Rectangle; using Lambda; -using StringTools; /** * A state dedicated to allowing the user to create and edit song charts. @@ -73,10 +85,11 @@ class ChartEditorState extends HaxeUIState 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_SONGDATA_LAYOUT = Paths.ui('chart-editor/toolbox/songdata'); - - static final CHART_EDITOR_DIALOG_ABOUT_LAYOUT = Paths.ui('chart-editor/dialogs/about'); - static final CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT = Paths.ui('chart-editor/dialogs/user-guide'); + 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; @@ -87,34 +100,65 @@ class ChartEditorState extends HaxeUIState // 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 = 0xC0FFFFFF; + static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF; static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030; - static final PLAYHEAD_COLOR:FlxColor = 0xC0808080; + 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 scrollPosition(default, set):Float = -1.0; + var scrollPositionInPixels(default, set):Float = -1.0; /** * scrollPosition, converted to steps. @@ -124,7 +168,7 @@ class ChartEditorState extends HaxeUIState function get_scrollPositionInSteps():Float { - return scrollPosition / GRID_SIZE; + return scrollPositionInPixels / GRID_SIZE; } /** @@ -140,7 +184,7 @@ class ChartEditorState extends HaxeUIState function set_scrollPositionInMs(value:Float):Float { - scrollPosition = value / Conductor.stepCrochet; + scrollPositionInPixels = value / Conductor.stepCrochet; return value; } @@ -150,7 +194,7 @@ class ChartEditorState extends HaxeUIState * 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 playheadPosition(default, set):Float; + var playheadPositionInPixels(default, set):Float; var playheadPositionInSteps(get, null):Float; @@ -159,7 +203,7 @@ class ChartEditorState extends HaxeUIState */ function get_playheadPositionInSteps():Float { - return playheadPosition / GRID_SIZE; + return playheadPositionInPixels / GRID_SIZE; } /** @@ -175,36 +219,59 @@ class ChartEditorState extends HaxeUIState /** * This is the song's length in PIXELS, same format as scrollPosition. */ - var songLength:Int; + 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, null):Float; + var songLengthInSteps(get, set):Float; function get_songLengthInSteps():Float { - return songLength / GRID_SIZE; + 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, null):Float; + 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; } @@ -227,7 +294,6 @@ class ChartEditorState extends HaxeUIState /** * The note kind to use for notes being placed in the chart. Defaults to `''`. - * Use the input in the sidebar to change this. */ var selectedNoteKind:String = ''; @@ -241,6 +307,16 @@ class ChartEditorState extends HaxeUIState */ 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. */ @@ -253,11 +329,31 @@ class ChartEditorState extends HaxeUIState // Make sure view is updated when we change view modes. noteDisplayDirty = true; notePreviewDirty = true; - this.scrollPosition = this.scrollPosition; + 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. @@ -269,6 +365,19 @@ class ChartEditorState extends HaxeUIState 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. */ @@ -314,7 +423,7 @@ class ChartEditorState extends HaxeUIState // Make sure view is updated when we change modes. noteDisplayDirty = true; notePreviewDirty = true; - this.scrollPosition = 0; + this.scrollPositionInPixels = 0; return isInPatternMode; } @@ -334,11 +443,49 @@ class ChartEditorState extends HaxeUIState var notePreviewDirty:Bool = true; /** - * Whether the difficulty tree view in the sidebar has been modified and needs to be updated. + * 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; /** @@ -351,6 +498,10 @@ class ChartEditorState extends HaxeUIState */ 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. */ @@ -367,6 +518,11 @@ class ChartEditorState extends HaxeUIState */ 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. @@ -391,9 +547,17 @@ class ChartEditorState extends HaxeUIState /** * The audio track for the vocals. - * TODO: Replace with a VocalSoundGroup. */ - var audioVocalTrack:FlxSound; + 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 @@ -533,7 +697,7 @@ class ChartEditorState extends HaxeUIState return value; } - var currentSongNoteSkin(get, set):String; + public var currentSongNoteSkin(get, set):String; function get_currentSongNoteSkin():String { @@ -584,6 +748,13 @@ class ChartEditorState extends HaxeUIState 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 @@ -631,10 +802,12 @@ class ChartEditorState extends HaxeUIState */ var gridPlayhead:FlxSpriteGroup; + var gridPlayheadScrollArea:FlxSprite; + /** - * A sprite used to highlight the grid square under the cursor. + * A sprite used to indicate the note that will be placed on click. */ - var gridCursor:FlxSprite; + var gridGhostNote:ChartEditorNoteSprite; /** * The waveform which (optionally) displays over the grid, underneath the notes and playhead. @@ -704,7 +877,6 @@ class ChartEditorState extends HaxeUIState currentTheme = ChartEditorTheme.Light; buildGrid(); - buildNoteGroup(); buildSelectionBox(); // Add the HaxeUI components after the grid so they're on top. @@ -714,8 +886,14 @@ class ChartEditorState extends HaxeUIState // 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. - loadMusic(); + // loadDefaultMusic(); + + // TODO: Change to false. + var canCloseInitialDialog = true; + ChartEditorDialogHandler.openWelcomeDialog(this, canCloseInitialDialog); } function buildDefaultSongData() @@ -728,6 +906,8 @@ class ChartEditorState extends HaxeUIState // Initialize the song chart data. songChartData = new Map(); + + audioVocalTrackGroup = new VocalGroup(); } /** @@ -754,26 +934,35 @@ class ChartEditorState extends HaxeUIState gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar. add(gridTiledSprite); - /* - buildSpectrogram(audioVocalTrack); - */ + gridGhostNote = new ChartEditorNoteSprite(this); + gridGhostNote.alpha = 0.6; + gridGhostNote.noteData = new SongNoteData(-1, -1, 0, ""); + gridGhostNote.visible = false; + add(gridGhostNote); - // The cursor that appears when hovering over the grid. - gridCursor = new FlxSprite().makeGraphic(GRID_SIZE, GRID_SIZE, CURSOR_COLOR); - add(gridCursor); + 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) + 10 + 10; + 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, Std.int(GRID_SIZE / 4), PLAYHEAD_COLOR); - playheadSprite.x = -10; + 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; @@ -820,8 +1009,12 @@ class ChartEditorState extends HaxeUIState function buildSpectrogram(target:FlxSound) { gridSpectrogram = new PolygonSpectogram(target, SPECTROGRAM_COLOR, FlxG.height / 2, Math.floor(FlxG.height / 2)); - gridSpectrogram.x = 0; - gridSpectrogram.y = 0; + // 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); @@ -850,17 +1043,19 @@ class ChartEditorState extends HaxeUIState */ } + var playbarHeadLayout:Component; + function buildAdditionalUI():Void { notifBar = cast buildComponent(CHART_EDITOR_NOTIFBAR_LAYOUT); add(notifBar); - var playbarHeadLayout:Component = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT); + playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT); - playbarHeadLayout.width = FlxG.width; + playbarHeadLayout.width = FlxG.width - 8; playbarHeadLayout.height = 10; - playbarHeadLayout.x = 0; + playbarHeadLayout.x = 4; playbarHeadLayout.y = FlxG.height - 48 - 8; playbarHead = playbarHeadLayout.findComponent('playbarHead', Slider); @@ -874,7 +1069,7 @@ class ChartEditorState extends HaxeUIState playbarHeadDragging = true; // If we were dragging the playhead while the song was playing, resume playing. - if (audioVocalTrack.playing) + if (audioInstTrack != null && audioInstTrack.playing) { playbarHeadDraggingWasPlaying = true; stopAudioPlayback(); @@ -890,7 +1085,7 @@ class ChartEditorState extends HaxeUIState playbarHeadDragging = false; // Set the song position to where the playhead was moved to. - scrollPosition = songLength * (playbarHead.value / 100); + scrollPositionInPixels = songLengthInPixels * (playbarHead.value / 100); // Update the conductor and audio tracks to match. moveSongToScrollPosition(); @@ -920,6 +1115,10 @@ class ChartEditorState extends HaxeUIState // 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()); @@ -971,48 +1170,84 @@ class ChartEditorState extends HaxeUIState // TODO: Implement this. }); - addUIClickListener('menubarItemAbout', (event:MouseEvent) -> openDialog(CHART_EDITOR_DIALOG_ABOUT_LAYOUT)); + addUIClickListener('menubarItemAbout', (event:MouseEvent) -> ChartEditorDialogHandler.openAboutDialog(this)); - addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> openDialog(CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT)); - - addUIChangeListener('menubarItemToggleSidebar', (event:UIEvent) -> - { - var sidebar:Component = findComponent('sidebar', Component); - - sidebar.visible = event.value; - }); - setUISelected('menubarItemToggleSidebar', true); + addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> ChartEditorDialogHandler.openUserGuideDialog(this)); addUIChangeListener('menubarItemDownscroll', (event:UIEvent) -> { isViewDownscroll = event.value; }); - setUISelected('menubarItemDownscroll', isViewDownscroll); + 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; }); - setUISelected('menubarItemMetronomeEnabled', shouldPlayMetronome); + 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; - audioInstTrack.volume = volume; + 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; - audioVocalTrack.volume = volume; + 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); }); - setUISelected('menubarItemToggleToolboxTools', true); - + // setUICheckboxSelected('menubarItemToggleToolboxTools', true); addUIChangeListener('menubarItemToggleToolboxNotes', (event:UIEvent) -> { ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value); @@ -1021,66 +1256,76 @@ class ChartEditorState extends HaxeUIState { ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value); }); - addUIChangeListener('menubarItemToggleToolboxSong', (event:UIEvent) -> + addUIChangeListener('menubarItemToggleToolboxDifficulty', (event:UIEvent) -> { - ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_SONGDATA_LAYOUT, event.value); + 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); }); - addUIClickListener('sidebarSaveMetadata', (event:MouseEvent) -> - { - // Save metadata for current variation. - SongSerializer.exportSongMetadata(currentSongMetadata); - }); - addUIClickListener('sidebarSaveChart', (event:MouseEvent) -> - { - // Save chart data for current variation. - SongSerializer.exportSongChartData(currentSongChartData); - }); - addUIClickListener('sidebarLoadMetadata', (event:MouseEvent) -> - { - // Replace metadata for current variation. - SongSerializer.importSongMetadataAsync(function(songMetadata:SongMetadata) - { - currentSongMetadata = songMetadata; - }); - }); - addUIClickListener('sidebarLoadChart', (event:MouseEvent) -> - { - // Replace chart data for current variation. - SongSerializer.importSongChartDataAsync(function(songChartData:SongChartData) - { - currentSongChartData = songChartData; - - noteDisplayDirty = true; - }); - }); - addUIChangeListener('sidebarSongName', (event:UIEvent) -> - { - // Set song name (for current variation) - currentSongName = event.value; - }); - setUIValue('sidebarSongName', currentSongName); - addUIChangeListener('sidebarSongArtist', (event:UIEvent) -> - { - currentSongArtist = event.value; - }); - setUIValue('sidebarSongArtist', currentSongArtist); - addUIChangeListener('sidebarStage', (event:UIEvent) -> - { - currentSongStage = event.value; - }); - setUIValue('sidebarStage', currentSongStage); - addUIChangeListener('sidebarNoteSkin', (event:UIEvent) -> - { - currentSongNoteSkin = event.value; - }); - setUIValue('sidebarNoteSkin', currentSongNoteSkin); // 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; @@ -1091,12 +1336,14 @@ class ChartEditorState extends HaxeUIState // These ones only happen if the modal dialog is not open. handleScrollKeybinds(); + // handleZoom(); + // handleSnap(); handleCursor(); handleMenubar(); - handleSidebar(); + handleToolboxes(); handlePlaybar(); - handlePlayheadKeybinds(); + handlePlayhead(); handleFileKeybinds(); handleEditKeybinds(); @@ -1104,11 +1351,21 @@ class ChartEditorState extends HaxeUIState handleHelpKeybinds(); // DEBUG + #if debug if (FlxG.keys.justPressed.F) { - showNotification('Hi there :)'); + // 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. @@ -1123,6 +1380,7 @@ class ChartEditorState extends HaxeUIState */ override function beatHit():Bool { + // dispatchEvent gets called here. if (!super.beatHit()) return false; @@ -1139,6 +1397,7 @@ class ChartEditorState extends HaxeUIState */ override function stepHit():Bool { + // dispatchEvent gets called here. if (!super.stepHit()) return false; @@ -1159,6 +1418,10 @@ class ChartEditorState extends HaxeUIState **/ 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. @@ -1198,7 +1461,7 @@ class ChartEditorState extends HaxeUIState } // Mouse Wheel = Scroll - if (FlxG.mouse.wheel != 0) + if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL) { scrollAmount = -10 * FlxG.mouse.wheel; } @@ -1218,7 +1481,7 @@ class ChartEditorState extends HaxeUIState // SHIFT + Scroll = Scroll Fast if (FlxG.keys.pressed.SHIFT) { - scrollAmount *= 10; + scrollAmount *= 5; } // CONTROL + Scroll = Scroll Precise if (FlxG.keys.pressed.CONTROL) @@ -1237,35 +1500,73 @@ class ChartEditorState extends HaxeUIState if (FlxG.keys.justPressed.HOME) { // Scroll amount is the difference between the current position and the top. - scrollAmount = 0 - this.scrollPosition; + scrollAmount = 0 - this.scrollPositionInPixels; + playheadAmount = 0 - this.playheadPositionInPixels; } if (playbarButtonPressed == 'playbarStart') { playbarButtonPressed = ''; - scrollAmount = 0 - this.scrollPosition; + 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.songLength - this.scrollPosition; + scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; } if (playbarButtonPressed == 'playbarEnd') { playbarButtonPressed = ''; - scrollAmount = this.songLength - this.scrollPosition; + scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; } // Apply the scroll amount. - this.scrollPosition += scrollAmount; - this.playheadPosition += playheadAmount; + 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. */ @@ -1273,24 +1574,65 @@ class ChartEditorState extends HaxeUIState { // 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; - if (FlxG.mouse.justPressed && (currentToolMode == ChartEditorToolMode.Select)) + 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) { - selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY); + 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; - var cursorStep:Int = Math.floor(cursorFractionalStep); - var cursorMs:Float = cursorStep * Conductor.stepCrochet; + 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) @@ -1313,7 +1655,7 @@ class ChartEditorState extends HaxeUIState } } - if ((currentToolMode == ChartEditorToolMode.Select) && selectionBoxStartPos != null) + if (selectionBoxStartPos != null) { var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x; var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y; @@ -1405,7 +1747,7 @@ class ChartEditorState extends HaxeUIState selectionBoxStartPos = null; setSelectionBoxBounds(); - if (FlxG.mouse.overlaps(gridTiledSprite)) + if (overlapsGrid) { // We clicked on the grid without moving the mouse. @@ -1436,8 +1778,6 @@ class ChartEditorState extends HaxeUIState { if (highlightedNote != null) { - // Handle the case of clicking on a sustain piece. - highlightedNote = highlightedNote.getBaseNoteSprite(); // Click to select an individual note and deselect everything else. if (isNoteSelected(highlightedNote.noteData)) { @@ -1471,7 +1811,7 @@ class ChartEditorState extends HaxeUIState } } } - else if ((currentToolMode == ChartEditorToolMode.Place) && currentPlaceNoteData != null) + else if (currentPlaceNoteData != null) { // Handle extending the note as you drag. @@ -1498,10 +1838,10 @@ class ChartEditorState extends HaxeUIState } else { - if ((currentToolMode == ChartEditorToolMode.Place) && FlxG.mouse.justPressed) + if (FlxG.mouse.justPressed) { // Just clicked to place a note. - if (FlxG.mouse.overlaps(gridTiledSprite)) + if (overlapsGrid && !overlapsSelectionBorder) { // We clicked on the grid without moving the mouse. @@ -1512,31 +1852,46 @@ class ChartEditorState extends HaxeUIState return note.alive && FlxG.mouse.overlaps(note); }); - if (highlightedNote != null) + if (FlxG.keys.pressed.CONTROL) { - // We clicked an existing note. - // Do nothing in Place mode so we don't accidentally double place a note. - } - else - { - // Click to place a note and select it. - var eventColumn = (STRUMLINE_SIZE * 2 + 1) - 1; - if (cursorColumn == eventColumn) + // Control click to select/deselect an individual note. + if (isNoteSelected(highlightedNote.noteData)) { - // Create an event and place it in the chart. - // TODO: Allow configuring the event to place from the sidebar. - var newEventData:SongEventData = new SongEventData(cursorMs, "test", {}); - - performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL)); + performCommand(new DeselectNotesCommand([highlightedNote.noteData])); } else { - // Create a note and place it in the chart. - var newNoteData:SongNoteData = new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind); + 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. - performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); + 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", {}); - currentPlaceNoteData = newNoteData; + 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; + } } } } @@ -1546,7 +1901,9 @@ class ChartEditorState extends HaxeUIState } } - if (FlxG.mouse.justPressedRight && FlxG.mouse.overlaps(gridTiledSprite)) + 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. @@ -1565,28 +1922,45 @@ class ChartEditorState extends HaxeUIState performCommand(new RemoveNotesCommand([highlightedNote.noteData])); } } - } - // Handle grid cursor. - if (FlxG.mouse.overlaps(gridTiledSprite)) - { - 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; - gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y; - } - else - { - gridCursor.visible = false; - gridCursor.x = -9999; - gridCursor.y = -9999; + // 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 { - gridCursor.visible = false; - gridCursor.x = -9999; - gridCursor.y = -9999; + gridGhostNote.visible = false; + } + + if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default) + { + Cursor.cursorMode = Pointer; } } @@ -1603,9 +1977,9 @@ class ChartEditorState extends HaxeUIState renderedNotes.flipX = (isViewDownscroll); // Calculate the view bounds. - var viewAreaTop:Float = this.scrollPosition - GRID_TOP_PAD; + var viewAreaTop:Float = this.scrollPositionInPixels - GRID_TOP_PAD; var viewHeight:Float = (FlxG.height - MENU_BAR_HEIGHT); - var viewAreaBottom:Float = this.scrollPosition + viewHeight; + var viewAreaBottom:Float = this.scrollPositionInPixels + viewHeight; // Remove notes that are no longer visible and list the ones that are. var displayedNoteData:Array = []; @@ -1640,7 +2014,11 @@ class ChartEditorState extends HaxeUIState } else { + // Note is already displayed and should remain displayed. displayedNoteData.push(noteSprite.noteData); + + // Update the note sprite's position. + noteSprite.updateNotePosition(renderedNotes); } } @@ -1649,8 +2027,11 @@ class ChartEditorState extends HaxeUIState { // 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. @@ -1664,11 +2045,11 @@ class ChartEditorState extends HaxeUIState // 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(ChartEditorNoteSprite); + var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this)); + noteSprite.parentState = this; // The note sprite handles animation playback and positioning. noteSprite.noteData = noteData; - noteSprite.noteSkin = currentSongNoteSkin; // Setting note data resets position relative to the grid so we fix that. noteSprite.x += renderedNotes.x; @@ -1690,6 +2071,7 @@ class ChartEditorState extends HaxeUIState } var nextNoteSprite:ChartEditorNoteSprite = renderedNotes.recycle(ChartEditorNoteSprite); + nextNoteSprite.parentState = this; nextNoteSprite.parentNoteSprite = lastNoteSprite; lastNoteSprite.childNoteSprite = nextNoteSprite; @@ -1739,6 +2121,10 @@ class ChartEditorState extends HaxeUIState */ 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; @@ -1785,12 +2171,40 @@ class ChartEditorState extends HaxeUIState 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) { @@ -1847,9 +2261,6 @@ class ChartEditorState extends HaxeUIState */ function handleViewKeybinds() { - // B = Toggle Sidebar - if (FlxG.keys.justPressed.B) - toggleSidebar(); } /** @@ -1859,71 +2270,132 @@ class ChartEditorState extends HaxeUIState { // F1 = Open Help if (FlxG.keys.justPressed.F1) - openDialog(CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT); + ChartEditorDialogHandler.openUserGuideDialog(this); } - function handleSidebar() + function handleToolboxes() + { + handleDifficultyToolbox(); + handlePlayerPreviewToolbox(); + handleOpponentPreviewToolbox(); + } + + function handleDifficultyToolbox() { if (difficultySelectDirty) { difficultySelectDirty = false; // Manage the Select Difficulty tree view. - var treeView:TreeView = findComponent('sidebarDifficulties'); + var difficultyToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + if (difficultyToolbox == null) + return; - if (treeView != null) + 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) { - // Clear the tree view so we can rebuild it. - treeView.clearNodes(); + var variationMetadata:SongMetadata = songMetadata.get(curVariation); - var treeSong = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName', icon: "haxeui-core/styles/default/haxeui_tiny.png"}); - treeSong.expanded = true; - - var treeVariationDefault = treeSong.addNode({ - id: 'stv_variation_default', - text: "V: Default", + var treeVariation = treeSong.addNode({ + id: 'stv_variation_$curVariation', + text: 'V: ${curVariation.toTitleCase()}', // icon: "haxeui-core/styles/default/haxeui_tiny.png" }); - treeVariationDefault.expanded = true; + treeVariation.expanded = true; - var treeDifficultyEasy = treeVariationDefault.addNode({ - id: 'stv_difficulty_default_easy', - text: "D: Easy", - // icon: "haxeui-core/styles/default/haxeui_tiny.png" - }); - var treeDifficultyNormal = treeVariationDefault.addNode({ - id: 'stv_difficulty_default_normal', - text: "D: Normal", - // icon: "haxeui-core/styles/default/haxeui_tiny.png" - }); - var treeDifficultyHard = treeVariationDefault.addNode({ - id: 'stv_difficulty_default_hard', - text: "D: Hard", - // icon: "haxeui-core/styles/default/haxeui_tiny.png" - }); + var difficultyList = variationMetadata.playData.difficulties; - var treeVariationErect = treeSong.addNode({ - id: 'stv_variation_erect', - text: "V: Erect", - // icon: "haxeui-core/styles/default/haxeui_tiny.png" - }); - treeVariationErect.expanded = true; + 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" + }); + } + } - var treeDifficultyErect = treeVariationErect.addNode({ - id: 'stv_difficulty_erect_erect', - text: "D: Erect", - // icon: "haxeui-core/styles/default/haxeui_tiny.png" - }); + treeView.onChange = onChangeTreeDifficulty; + treeView.selectedNode = getCurrentTreeDifficultyNode(); + } + } - 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('sidebarDifficulties'); + var treeView:TreeView = findComponent('difficultyToolboxTree'); if (treeView == null) return null; @@ -1971,6 +2443,18 @@ class ChartEditorState extends HaxeUIState } } + 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. */ @@ -2072,36 +2556,39 @@ class ChartEditorState extends HaxeUIState */ function handleMusicPlayback() { - if (audioInstTrack.playing) + 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 - audioVocalTrack.time) > 100) - audioVocalTrack.time = audioInstTrack.time; + if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + audioVocalTrackGroup.time = audioInstTrack.time; var diffStepTime = Conductor.currentStepTime - oldStepTime; // Move the playhead. - playheadPosition += diffStepTime * GRID_SIZE; + 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 (Math.abs(audioInstTrack.time - audioVocalTrack.time) > 100) - audioVocalTrack.time = audioInstTrack.time; + 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. - scrollPosition = Conductor.currentStepTime * GRID_SIZE - playheadPosition; + scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels; // DO NOT move song to scroll position here specifically. @@ -2110,26 +2597,86 @@ class ChartEditorState extends HaxeUIState } } - if (FlxG.keys.justPressed.SPACE) + 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() { - audioInstTrack.play(); - audioVocalTrack.play(); + if (audioInstTrack != null) + audioInstTrack.play(); + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.play(); + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.play(); } function stopAudioPlayback() { - audioInstTrack.pause(); - audioVocalTrack.pause(); + 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(); @@ -2140,7 +2687,7 @@ class ChartEditorState extends HaxeUIState } } - function handlePlayheadKeybinds() + function handlePlayhead() { // Place notes at the playhead. // TODO: Add the ability to switch modes. @@ -2167,125 +2714,214 @@ class ChartEditorState extends HaxeUIState function placeNoteAtPlayhead(column:Int):Void { - var gridSnappedPlayheadPos = scrollPosition - (scrollPosition % GRID_SIZE); + var gridSnappedPlayheadPos = scrollPositionInPixels - (scrollPositionInPixels % GRID_SIZE); } - function set_scrollPosition(value:Float):Float + 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 (playheadPosition > 0) + if (playheadPositionInPixels > 0) { - var amount = scrollPosition - value; - playheadPosition -= amount; + var amount = scrollPositionInPixels - value; + playheadPositionInPixels -= amount; } value = 0; } - if (value > songLength) - value = songLength; + if (value > songLengthInPixels) + value = songLengthInPixels; - if (value == scrollPosition) + if (value == scrollPositionInPixels) return value; - this.scrollPosition = value; + this.scrollPositionInPixels = value; // Move the grid sprite to the correct position. if (isViewDownscroll) { - gridTiledSprite.y = -scrollPosition + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); } else { - gridTiledSprite.y = -scrollPosition + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + 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.scrollPosition; + return this.scrollPositionInPixels; } - function set_playheadPosition(value:Float):Float + function get_playheadPositionInPixels():Float + { + return this.playheadPositionInPixels; + } + + function set_playheadPositionInPixels(value:Float):Float { // Make sure playhead doesn't go outside the song. - if (value + scrollPosition < 0) - value = -scrollPosition; - if (value + scrollPosition > songLength) - value = songLength - scrollPosition; + if (value + scrollPositionInPixels < 0) + value = -scrollPositionInPixels; + if (value + scrollPositionInPixels > songLengthInPixels) + value = songLengthInPixels - scrollPositionInPixels; - this.playheadPosition = value; + this.playheadPositionInPixels = value; // Move the playhead sprite to the correct position. - gridPlayhead.y = this.playheadPosition + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); - return this.playheadPosition; + return this.playheadPositionInPixels; } /** - * Show the sidebar if it's hidden, or hide it if it's shown. + * Loads an instrumental from an absolute file path, replacing the current instrumental. */ - function toggleSidebar() + public function loadInstrumentalFromPath(path:String):Void { - var sidebar:Component = findComponent('sidebar', Component); - - // Set visibility while syncing the checkbox. - if (sidebar != null) - { - sidebar.visible = setUISelected('menubarItemToggleSidebar', !sidebar.visible); - } + #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 } /** - * Builds and opens a dialog from a given layout path. - * @param modal Makes the background uninteractable while the dialog is open. + * Loads an instrumental from audio byte data, replacing the current instrumental. */ - function openDialog(key:String, modal:Bool = true) + public function loadInstrumentalFromBytes(bytes:haxe.io.Bytes):Void { - var dialog:Dialog = cast buildComponent(key); - dialog.showDialog(modal); - } - - /** - * Load a music track for playback. - */ - function loadMusic() - { - // TODO: How to load music by selecting with a file dialog? - audioInstTrack = FlxG.sound.play(Paths.inst('dadbattle'), 1.0, false); + 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() { - audioInstTrack.pause(); - audioVocalTrack.pause(); + if (audioInstTrack != null) + audioInstTrack.pause(); + if (audioVocalTrackGroup != null) + audioVocalTrackGroup.pause(); }; - audioVocalTrack = FlxG.sound.play(Paths.voices('dadbattle'), 1.0, false); - audioVocalTrack.autoDestroy = false; - audioVocalTrack.pause(); + songLengthInMs = audioInstTrack.length; - // TODO: Make sure Conductor works properly with changing BPMs. - var DAD_BATTLE_BPM = 180; - var BOPEEBO_BPM = 100; - Conductor.forceBPM(DAD_BATTLE_BPM); - - songLength = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * GRID_SIZE); - - gridTiledSprite.height = songLength; + gridTiledSprite.height = songLengthInPixels; if (gridSpectrogram != null) - gridSpectrogram.setSound(audioVocalTrack); + { + gridSpectrogram.setSound(audioInstTrack); + gridSpectrogram.generateSection(0, songLengthInMs / 1000); + } - scrollPosition = 0; - playheadPosition = 0; + 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. @@ -2296,92 +2932,18 @@ class ChartEditorState extends HaxeUIState Conductor.update(scrollPositionInMs); // Update the songPosition in the audio tracks. - audioInstTrack.time = scrollPositionInMs + playheadPositionInMs; - audioVocalTrack.time = scrollPositionInMs + playheadPositionInMs; + 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; } - /** - * Add an onClick listener to a HaxeUI menu bar item. - **/ - function addUIClickListener(key:String, callback:MouseEvent->Void) - { - var target:Component = findComponent(key); - if (target == null) - { - // Gracefully handle the case where the item can't be located. - trace('WARN: Could not locate menu item: $key'); - } - else - { - target.onClick = callback; - } - } - - /** - * Add an onChange listener to a HaxeUI menu bar item such as a slider. - */ - function addUIChangeListener(key:String, callback:UIEvent->Void) - { - var target:Component = findComponent(key); - if (target == null) - { - // Gracefully handle the case where the item can't be located. - trace('WARN: Could not locate menu item: $key'); - } - else - { - target.onChange = callback; - } - } - - /** - * Set the value of a HaxeUI component. - * Usually modifies the text of a label. - */ - function setUIValue(key:String, value:T):T - { - var target:Component = findComponent(key); - if (target == null) - { - // Gracefully handle the case where the item can't be located. - trace('WARN: Could not locate menu item: $key'); - return value; - } - else - { - return target.value = value; - } - } - - /** - * Set the value of a HaxeUI checkbox, - * since that's on 'selected' instead of 'value'. - */ - function setUISelected(key:String, value:Bool):Bool - { - var targetA:CheckBox = findComponent(key, CheckBox); - - if (targetA != null) - { - return targetA.selected = value; - } - - var targetB:MenuCheckBox = findComponent(key, MenuCheckBox); - if (targetB != null) - { - return targetB.selected = value; - } - - // Gracefully handle the case where the item can't be located. - trace('WARN: Could not locate check box: $key'); - return value; - } - /** * 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. */ @@ -2475,6 +3037,8 @@ class ChartEditorState extends HaxeUIState { super.destroy(); + cleanupAutoSave(); + @:privateAccess ChartEditorNoteSprite.noteFrameCollection = null; } @@ -2515,38 +3079,62 @@ class ChartEditorState extends HaxeUIState } /** - * Displays a prompt to the user, to save their changes made to this chart, - * or to discard them. - * - * @param onComplete Function to call after the user clicks Save or Don't Save. - * If Save was clicked, we save before calling this. - * @param onCancel Function to call if the user clicks Cancel. + * @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. */ - function promptSaveChanges(onComplete:Void->Void, ?onCancel:Void->Void):Void + public function exportAllSongData(?force:Bool = false, ?tmp:Bool = false):Void { - var messageBox:MessageBox = new MessageBox(); + var zipEntries = []; - messageBox.title = 'Save Changes?'; - messageBox.message = 'Do you want to save the changes you made to $currentSongName?\n\nYour changes will be lost if you don\'t save them.'; - messageBox.type = 'question'; - messageBox.modal = true; - messageBox.buttons = DialogButton.SAVE | "Don't Save" | "Cancel"; - - messageBox.registerEvent(DialogEvent.DIALOG_CLOSED, function(e:DialogEvent):Void + for (variation in availableVariations) { - trace('Pressed: ${e.button}'); - switch (e.button) + var variationId = variation; + if (variation == '' || variation == 'default' || variation == 'normal') { - case 'Save': - // TODO: Make sure to actually save. - // saveChart(); - onComplete(); - case "Don't Save": - onComplete(); - case 'Cancel': - if (onCancel != null) - onCancel(); + 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/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx index 46e9837b9..317f4c632 100644 --- a/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx @@ -1,5 +1,6 @@ package funkin.ui.debug.charting; +import flixel.FlxSprite; import flixel.addons.display.FlxGridOverlay; import flixel.addons.display.FlxSliceSprite; import flixel.math.FlxRect; @@ -32,18 +33,24 @@ class ChartEditorThemeHandler static final GRID_COLOR_1_DARK:FlxColor = 0xFF181919; // Color 2 of the grid pattern. Alternates with Color 1. - static final GRID_COLOR_2_LIGHT:FlxColor = 0xFFD9D5D5; - static final GRID_COLOR_2_DARK:FlxColor = 0xFF262A2A; + static final GRID_COLOR_2_LIGHT:FlxColor = 0xFFF8F8F8; + static final GRID_COLOR_2_DARK:FlxColor = 0xFF202020; + + // Color 3 of the grid pattern. Borders the other colors. + static final GRID_COLOR_3_LIGHT:FlxColor = 0xFFD9D5D5; + static final GRID_COLOR_3_DARK:FlxColor = 0xFF262A2A; // Vertical divider between characters. - static final GRID_STRUMLINE_DIVIDER_COLOR_LIGHT:FlxColor = 0xFF000000; + static final GRID_STRUMLINE_DIVIDER_COLOR_LIGHT:FlxColor = 0xFF111111; static final GRID_STRUMLINE_DIVIDER_COLOR_DARK:FlxColor = 0xFFC4C4C4; - static final GRID_STRUMLINE_DIVIDER_WIDTH:Float = 2; + // static final GRID_STRUMLINE_DIVIDER_WIDTH:Float = 2; + static final GRID_STRUMLINE_DIVIDER_WIDTH:Float = ChartEditorState.GRID_SELECTION_BORDER_WIDTH; // Horizontal divider between measures. - static final GRID_MEASURE_DIVIDER_COLOR_LIGHT:FlxColor = 0xFF000000; + static final GRID_MEASURE_DIVIDER_COLOR_LIGHT:FlxColor = 0xFF111111; static final GRID_MEASURE_DIVIDER_COLOR_DARK:FlxColor = 0xFFC4C4C4; - static final GRID_MEASURE_DIVIDER_WIDTH:Float = 2; + // static final GRID_MEASURE_DIVIDER_WIDTH:Float = 2; + static final GRID_MEASURE_DIVIDER_WIDTH:Float = ChartEditorState.GRID_SELECTION_BORDER_WIDTH; // Border on the square highlighting selected notes. static final SELECTION_SQUARE_BORDER_COLOR_LIGHT:FlxColor = 0xFF339933; @@ -55,9 +62,9 @@ class ChartEditorThemeHandler static final SELECTION_SQUARE_FILL_COLOR_LIGHT:FlxColor = 0x4033FF33; static final SELECTION_SQUARE_FILL_COLOR_DARK:FlxColor = 0x4033FF33; - // TODO: Un-hardcode these to be based on time signature. - static final STEPS_PER_BEAT:Int = 4; - static final BEATS_PER_MEASURE:Int = 4; + static final PLAYHEAD_BLOCK_BORDER_WIDTH:Int = 2; + static final PLAYHEAD_BLOCK_BORDER_COLOR:FlxColor = 0xFF9D0011; + static final PLAYHEAD_BLOCK_FILL_COLOR:FlxColor = 0xFFBD0231; public static function updateTheme(state:ChartEditorState):Void { @@ -100,11 +107,70 @@ class ChartEditorThemeHandler // 2 * (Strumline Size) + 1 grid squares wide, by (4 * quarter notes per measure) grid squares tall. // This gets reused to fill the screen. - var gridWidth = ChartEditorState.GRID_SIZE * (ChartEditorState.STRUMLINE_SIZE * 2 + 1); - var gridHeight = ChartEditorState.GRID_SIZE * (STEPS_PER_BEAT * BEATS_PER_MEASURE); + var gridWidth:Int = Std.int(ChartEditorState.GRID_SIZE * (ChartEditorState.STRUMLINE_SIZE * 2 + 1)); + var gridHeight:Int = Std.int(ChartEditorState.GRID_SIZE * (Conductor.stepsPerMeasure)); state.gridBitmap = FlxGridOverlay.createGrid(ChartEditorState.GRID_SIZE, ChartEditorState.GRID_SIZE, gridWidth, gridHeight, true, gridColor1, gridColor2); + // Selection borders + var selectionBorderColor:FlxColor = switch (state.currentTheme) + { + case Light: GRID_COLOR_3_LIGHT; + case Dark: GRID_COLOR_3_DARK; + default: GRID_COLOR_3_LIGHT; + }; + + // Selection border at top. + state.gridBitmap.fillRect(new Rectangle(0, -(ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), state.gridBitmap.width, + ChartEditorState.GRID_SELECTION_BORDER_WIDTH), + selectionBorderColor); + + // Selection borders in the middle. + for (i in 1...(Conductor.stepsPerMeasure)) + { + state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), + state.gridBitmap.width, ChartEditorState.GRID_SELECTION_BORDER_WIDTH), + selectionBorderColor); + } + + // Selection border at bottom. + state.gridBitmap.fillRect(new Rectangle(0, state.gridBitmap.height - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), state.gridBitmap.width, + ChartEditorState.GRID_SELECTION_BORDER_WIDTH), + selectionBorderColor); + + // Selection border at left. + state.gridBitmap.fillRect(new Rectangle(-(ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), 0, ChartEditorState.GRID_SELECTION_BORDER_WIDTH, + state.gridBitmap.height), + selectionBorderColor); + + // Selection borders across the middle. + for (i in 1...(ChartEditorState.STRUMLINE_SIZE * 2 + 1)) + { + state.gridBitmap.fillRect(new Rectangle((ChartEditorState.GRID_SIZE * i) - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), 0, + ChartEditorState.GRID_SELECTION_BORDER_WIDTH, state.gridBitmap.height), + selectionBorderColor); + } + + // Selection border at right. + state.gridBitmap.fillRect(new Rectangle(state.gridBitmap.width - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), 0, + ChartEditorState.GRID_SELECTION_BORDER_WIDTH, state.gridBitmap.height), + selectionBorderColor); + + // Draw dividers between the measures. + + var gridMeasureDividerColor:FlxColor = switch (state.currentTheme) + { + case Light: GRID_MEASURE_DIVIDER_COLOR_LIGHT; + case Dark: GRID_MEASURE_DIVIDER_COLOR_DARK; + default: GRID_MEASURE_DIVIDER_COLOR_LIGHT; + }; + + // Divider at top + state.gridBitmap.fillRect(new Rectangle(0, 0, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor); + // Divider at bottom + var dividerLineBY = state.gridBitmap.height - (GRID_MEASURE_DIVIDER_WIDTH / 2); + state.gridBitmap.fillRect(new Rectangle(0, dividerLineBY, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor); + // Draw dividers between the strumlines. var gridStrumlineDividerColor:FlxColor = switch (state.currentTheme) @@ -121,20 +187,10 @@ class ChartEditorThemeHandler var dividerLineBX = ChartEditorState.GRID_SIZE * (ChartEditorState.STRUMLINE_SIZE * 2) - (GRID_STRUMLINE_DIVIDER_WIDTH / 2); state.gridBitmap.fillRect(new Rectangle(dividerLineBX, 0, GRID_STRUMLINE_DIVIDER_WIDTH, state.gridBitmap.height), gridStrumlineDividerColor); - // Draw dividers between the measures. - - var gridMeasureDividerColor:FlxColor = switch (state.currentTheme) - { - case Light: GRID_MEASURE_DIVIDER_COLOR_LIGHT; - case Dark: GRID_MEASURE_DIVIDER_COLOR_DARK; - default: GRID_MEASURE_DIVIDER_COLOR_LIGHT; - }; - - // Divider at top - state.gridBitmap.fillRect(new Rectangle(0, 0, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor); - // Divider at bottom - var dividerLineBY = state.gridBitmap.height - (GRID_MEASURE_DIVIDER_WIDTH / 2); - state.gridBitmap.fillRect(new Rectangle(0, dividerLineBY, GRID_MEASURE_DIVIDER_WIDTH / 2, state.gridBitmap.height), gridMeasureDividerColor); + if (state.gridTiledSprite != null) { + state.gridTiledSprite.loadGraphic(state.gridBitmap); + } + // Else, gridTiledSprite will be built later. } static function updateSelectionSquare(state:ChartEditorState):Void @@ -169,4 +225,20 @@ class ChartEditorThemeHandler - (2 * SELECTION_SQUARE_BORDER_WIDTH + 8)), 32, 32); } + + public static function buildPlayheadBlock():FlxSprite + { + var playheadBlock:FlxSprite = new FlxSprite(); + + var playheadBlockBitmap:BitmapData = new BitmapData(ChartEditorState.PLAYHEAD_SCROLL_AREA_WIDTH, ChartEditorState.PLAYHEAD_HEIGHT * 2, true); + + playheadBlockBitmap.fillRect(new Rectangle(0, 0, ChartEditorState.PLAYHEAD_SCROLL_AREA_WIDTH, ChartEditorState.PLAYHEAD_HEIGHT * 2), + PLAYHEAD_BLOCK_BORDER_COLOR); + playheadBlockBitmap.fillRect(new Rectangle(PLAYHEAD_BLOCK_BORDER_WIDTH, PLAYHEAD_BLOCK_BORDER_WIDTH, + ChartEditorState.PLAYHEAD_SCROLL_AREA_WIDTH - (2 * PLAYHEAD_BLOCK_BORDER_WIDTH), + ChartEditorState.PLAYHEAD_HEIGHT * 2 - (2 * PLAYHEAD_BLOCK_BORDER_WIDTH)), + PLAYHEAD_BLOCK_FILL_COLOR); + + return playheadBlock.loadGraphic(playheadBlockBitmap); + } } diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx index c7137a952..ab45e2e90 100644 --- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx @@ -1,5 +1,14 @@ 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 funkin.play.character.BaseCharacter.CharacterType; +import funkin.ui.haxeui.components.CharacterPlayer; +import funkin.play.song.SongSerializer; +import haxe.ui.components.Button; import haxe.ui.components.DropDown; import haxe.ui.containers.Group; import haxe.ui.containers.dialogs.Dialog; @@ -77,33 +86,58 @@ class ChartEditorToolboxHandler toolbox = buildToolboxNoteDataLayout(state); case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT: toolbox = buildToolboxEventDataLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_SONGDATA_LAYOUT: - toolbox = buildToolboxSongDataLayout(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.setUISelected('menubarItemToggleToolboxTools', false); + state.setUICheckboxSelected('menubarItemToggleToolboxTools', false); } var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group); + if (toolsGroup == null) return null; + toolsGroup.onChange = (event:UIEvent) -> { switch (event.target.id) @@ -124,13 +158,15 @@ class ChartEditorToolboxHandler { 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.setUISelected('menubarItemToggleToolboxNotes', false); + state.setUICheckboxSelected('menubarItemToggleToolboxNotes', false); } var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown); @@ -147,38 +183,251 @@ class ChartEditorToolboxHandler { 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.setUISelected('menubarItemToggleToolboxEvents', false); + state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false); } return toolbox; } - static function buildToolboxSongDataLayout(state:ChartEditorState):Dialog + static function buildToolboxDifficultyLayout(state:ChartEditorState):Dialog { - var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_SONGDATA_LAYOUT); + var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + + if (toolbox == null) return null; // Starting position. - toolbox.x = 950; - toolbox.y = 50; + toolbox.x = 125; + toolbox.y = 200; toolbox.onDialogClosed = (event:DialogEvent) -> { - state.setUISelected('menubarItemToggleToolboxSong', false); + 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 buildDialog(state:ChartEditorState, id:String):Dialog + static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Dialog { - var dialog:Dialog = cast state.buildComponent(id); - dialog.destroyOnClose = false; - return 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/HaxeUIState.hx b/source/funkin/ui/haxeui/HaxeUIState.hx index 488ca07cb..f7da230bb 100644 --- a/source/funkin/ui/haxeui/HaxeUIState.hx +++ b/source/funkin/ui/haxeui/HaxeUIState.hx @@ -1,5 +1,10 @@ package funkin.ui.haxeui; +import haxe.ui.containers.menus.MenuCheckBox; +import haxe.ui.components.CheckBox; +import haxe.ui.events.DragEvent; +import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; import haxe.ui.RuntimeComponentBuilder; import haxe.ui.core.Component; import haxe.ui.core.Screen; @@ -93,6 +98,83 @@ class HaxeUIState extends MusicBeatState } } + /** + * Add an onClick listener to a HaxeUI menu bar item. + */ + function addUIClickListener(key:String, callback:MouseEvent->Void) + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.onClick = callback; + } + } + + /** + * Add an onChange listener to a HaxeUI input component such as a slider or text field. + */ + function addUIChangeListener(key:String, callback:UIEvent->Void) + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.onChange = callback; + } + } + + /** + * Set the value of a HaxeUI component. + * Usually modifies the text of a label or value of a text field. + */ + function setUIValue(key:String, value:T):T + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + return value; + } + else + { + return target.value = value; + } + } + + /** + * Set the value of a HaxeUI checkbox, + * since that's on 'selected' instead of 'value'. + */ + public function setUICheckboxSelected(key:String, value:Bool):Bool + { + var targetA:CheckBox = findComponent(key, CheckBox); + + if (targetA != null) + { + return targetA.selected = value; + } + + var targetB:MenuCheckBox = findComponent(key, MenuCheckBox); + if (targetB != null) + { + return targetB.selected = value; + } + + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate check box: $key'); + return value; + } + public function findComponent(criteria:String = null, type:Class = null, recursive:Null = null, searchType:String = "id"):Null { if (component == null) diff --git a/source/funkin/ui/haxeui/components/CharacterPlayer.hx b/source/funkin/ui/haxeui/components/CharacterPlayer.hx new file mode 100644 index 000000000..37018b760 --- /dev/null +++ b/source/funkin/ui/haxeui/components/CharacterPlayer.hx @@ -0,0 +1,276 @@ +package funkin.ui.haxeui.components; + +import flixel.FlxSprite; +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.graphics.frames.FlxFramesCollection; +import flixel.math.FlxRect; +import funkin.modding.events.ScriptEvent; +import funkin.modding.IScriptedClass.IPlayStateScriptedClass; +import funkin.play.character.BaseCharacter; +import funkin.play.character.CharacterData.CharacterDataParser; +import haxe.ui.containers.Box; +import haxe.ui.core.Component; +import haxe.ui.core.IDataComponent; +import haxe.ui.data.DataSource; +import haxe.ui.events.AnimationEvent; +import haxe.ui.events.UIEvent; +import haxe.ui.geom.Size; +import haxe.ui.layouts.DefaultLayout; +import haxe.ui.styles.Style; +import openfl.Assets; + +private typedef AnimationInfo = +{ + var name:String; + var prefix:String; + var frameRate:Null; // default 30 + var looped:Null; // default true + var flipX:Null; // default false + var flipY:Null; // default false +} + +@:composite(Layout) +class CharacterPlayer extends Box +{ + private var character:BaseCharacter; + + public function new(?defaultToBf:Bool = true) + { + super(); + this._overrideSkipTransformChildren = false; + + if (defaultToBf) + { + loadCharacter('bf'); + } + } + + private var _charId:String; + + public var charId(get, set):String; + + private function get_charId():String + { + return _charId; + } + + private function set_charId(value:String):String + { + _charId = value; + loadCharacter(_charId); + return value; + } + + private var _redispatchLoaded:Bool = false; // possible haxeui bug: if listener is added after event is dispatched, event is "lost"... needs thinking about, is it smart to "collect and redispatch"? Not sure + private var _redispatchStart:Bool = false; // possible haxeui bug: if listener is added after event is dispatched, event is "lost"... needs thinking about, is it smart to "collect and redispatch"? Not sure + + public override function onReady() + { + super.onReady(); + + invalidateComponentLayout(); + + if (_redispatchLoaded) + { + _redispatchLoaded = false; + dispatch(new AnimationEvent(AnimationEvent.LOADED)); + } + + if (_redispatchStart) + { + _redispatchStart = false; + dispatch(new AnimationEvent(AnimationEvent.START)); + } + + parentComponent._overrideSkipTransformChildren = false; + } + + public function loadCharacter(id:String) + { + if (id == null) + { + return; + } + + if (character != null) + { + remove(character); + character.destroy(); + character = null; + } + + var newCharacter:BaseCharacter = CharacterDataParser.fetchCharacter(id); + + if (newCharacter == null) + { + return; + } + + character = newCharacter; + if (_characterType != null) + { + character.characterType = _characterType; + } + if (flip) + { + character.flipX = !character.flipX; + } + + character.scale.x *= _scale; + character.scale.y *= _scale; + + character.animation.callback = function(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1) + { + @:privateAccess + character.onAnimationFrame(name, frameNumber, frameIndex); + dispatch(new AnimationEvent(AnimationEvent.FRAME)); + }; + character.animation.finishCallback = function(name:String = "") + { + @:privateAccess + character.onAnimationFinished(name); + dispatch(new AnimationEvent(AnimationEvent.END)); + }; + add(character); + + invalidateComponentLayout(); + + if (hasEvent(AnimationEvent.LOADED)) + { + dispatch(new AnimationEvent(AnimationEvent.LOADED)); + } + else + { + _redispatchLoaded = true; + } + } + + private override function repositionChildren() + { + super.repositionChildren(); + + @:privateAccess + var animOffsets = character.animOffsets; + + character.x = this.screenX + ((this.width / 2) - (character.frameWidth / 2)); + character.x -= animOffsets[0]; + character.y = this.screenY + ((this.height / 2) - (character.frameHeight / 2)); + character.y -= animOffsets[1]; + } + + private var _characterType:CharacterType; + public function setCharacterType(value:CharacterType) + { + _characterType = value; + if (character != null) + { + character.characterType = value; + } + } + + public var flip(default, set):Bool; + + private function set_flip(value:Bool):Bool + { + if (value == flip) + return value; + + if (character != null) + { + character.flipX = !character.flipX; + } + + return flip = value; + } + + var _scale:Float = 1.0; + public function setScale(value) + { + _scale = value; + if (character != null) + { + character.scale.x *= _scale; + character.scale.y *= _scale; + } + } + + public function onUpdate(event:UpdateScriptEvent) + { + if (character != null) + character.onUpdate(event); + } + + public function onBeatHit(event:SongTimeScriptEvent):Void + { + if (character != null) + character.onBeatHit(event); + + this.repositionChildren(); + } + + public function onStepHit(event:SongTimeScriptEvent):Void + { + if (character != null) + character.onStepHit(event); + } + + public function onNoteHit(event:NoteScriptEvent):Void + { + if (character != null) + character.onNoteHit(event); + + this.repositionChildren(); + } + + public function onNoteMiss(event:NoteScriptEvent):Void + { + if (character != null) + character.onNoteMiss(event); + + this.repositionChildren(); + } + + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void + { + if (character != null) + character.onNoteGhostMiss(event); + + this.repositionChildren(); + } +} + +@:access(funkin.ui.haxeui.components.CharacterPlayer) +private class Layout extends DefaultLayout +{ + public override function repositionChildren() + { + var player = cast(_component, CharacterPlayer); + var sprite:BaseCharacter = player.character; + if (sprite == null) + { + return super.repositionChildren(); + } + + @:privateAccess + var animOffsets = sprite.animOffsets; + + sprite.x = _component.screenLeft + ((_component.width / 2) - (sprite.frameWidth / 2)); + sprite.x += animOffsets[0]; + sprite.y = _component.screenTop + ((_component.height / 2) - (sprite.frameHeight / 2)); + sprite.y += animOffsets[1]; + } + + public override function calcAutoSize(exclusions:Array = null):Size + { + var player = cast(_component, CharacterPlayer); + var sprite = player.character; + if (sprite == null) + { + return super.calcAutoSize(exclusions); + } + var size = new Size(); + size.width = sprite.frameWidth + paddingLeft + paddingRight; + size.height = sprite.frameHeight + paddingTop + paddingBottom; + return size; + } +} diff --git a/source/funkin/util/DateUtil.hx b/source/funkin/util/DateUtil.hx new file mode 100644 index 000000000..32db7804d --- /dev/null +++ b/source/funkin/util/DateUtil.hx @@ -0,0 +1,11 @@ +package funkin.util; + +class DateUtil +{ + public static function generateTimestamp():String + { + var date = Date.now(); + return + '${date.getFullYear()}-${Std.string(date.getMonth() + 1).lpad('0', 2)}-${Std.string(date.getDate()).lpad('0', 2)}-${Std.string(date.getHours()).lpad('0', 2)}-${Std.string(date.getMinutes()).lpad('0', 2)}-${Std.string(date.getSeconds()).lpad('0', 2)}'; + } +} diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx new file mode 100644 index 000000000..e3dafc2cb --- /dev/null +++ b/source/funkin/util/FileUtil.hx @@ -0,0 +1,524 @@ +package funkin.util; + +import cpp.abi.Abi; +import haxe.zip.Entry; +import lime.utils.Bytes; +import lime.ui.FileDialog; +import openfl.net.FileFilter; +import haxe.io.Path; +import lime.utils.Resource; + +/** + * Utilities for reading and writing files on various platforms. + */ +class FileUtil +{ + /** + * Browses for a single file, then calls `onSelect(path)` when a path chosen. + * Note that on HTML5 this will immediately fail, you should call `openFile(onOpen:Resource->Void)` instead. + * + * @param typeFilter Filters what kinds of files can be selected. + * @return Whether the file dialog was opened successfully. + */ + public static function browseForFile(?typeFilter:Array, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + #if desktop + var filter = convertTypeFilter(typeFilter); + + var fileDialog = new FileDialog(); + if (onSelect != null) + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + fileDialog.onCancel.add(onCancel); + + fileDialog.browse(OPEN, filter, defaultPath, dialogTitle); + return true; + #elseif html5 + onCancel(); + return false; + #else + onCancel(); + return false; + #end + } + + /** + * Browses for a directory, then calls `onSelect(path)` when a path chosen. + * Note that on HTML5 this will immediately fail. + * + * @param typeFilter TODO What does this do? + * @return Whether the file dialog was opened successfully. + */ + public static function browseForDirectory(?typeFilter:Array, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + #if desktop + var filter = convertTypeFilter(typeFilter); + + var fileDialog = new FileDialog(); + if (onSelect != null) + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + fileDialog.onCancel.add(onCancel); + + fileDialog.browse(OPEN_DIRECTORY, filter, defaultPath, dialogTitle); + return true; + #elseif html5 + onCancel(); + return false; + #else + onCancel(); + return false; + #end + } + + /** + * Browses for multiple file, then calls `onSelect(paths)` when a path chosen. + * Note that on HTML5 this will immediately fail. + * + * @return Whether the file dialog was opened successfully. + */ + public static function browseForMultipleFiles(?typeFilter:Array, ?onSelect:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + #if desktop + var filter = convertTypeFilter(typeFilter); + + var fileDialog = new FileDialog(); + if (onSelect != null) + fileDialog.onSelectMultiple.add(onSelect); + if (onCancel != null) + fileDialog.onCancel.add(onCancel); + + fileDialog.browse(OPEN_MULTIPLE, filter, defaultPath, dialogTitle); + return true; + #elseif html5 + onCancel(); + return false; + #else + onCancel(); + return false; + #end + } + + /** + * Browses for a file location to save to, then calls `onSelect(path)` when a path chosen. + * Note that on HTML5 you can't do much with this, you should call `saveFile(resource:haxe.io.Bytes)` instead. + * + * @param typeFilter TODO What does this do? + * @return Whether the file dialog was opened successfully. + */ + public static function browseForSaveFile(?typeFilter:Array, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + #if desktop + var filter = convertTypeFilter(typeFilter); + + var fileDialog = new FileDialog(); + if (onSelect != null) + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + fileDialog.onCancel.add(onCancel); + + fileDialog.browse(SAVE, filter, defaultPath, dialogTitle); + return true; + #elseif html5 + onCancel(); + return false; + #else + onCancel(); + return false; + #end + } + + /** + * Browses for a single file location, then reads it and passes it to `onOpen(resource:haxe.io.Bytes)`. + * Works great on desktop and HTML5. + * + * @param typeFilter TODO What does this do? + * @return Whether the file dialog was opened successfully. + */ + public static function openFile(?typeFilter:Array, ?onOpen:Bytes->Void, ?onCancel:Void->Void, ?defaultPath:String, ?dialogTitle:String):Bool + { + #if desktop + var filter = convertTypeFilter(typeFilter); + + var fileDialog = new FileDialog(); + if (onOpen != null) + fileDialog.onOpen.add(onOpen); + if (onCancel != null) + fileDialog.onCancel.add(onCancel); + + fileDialog.open(filter, defaultPath, dialogTitle); + return true; + #elseif html5 + var filter = convertTypeFilter(typeFilter); + + var onFileLoaded = function(event) + { + var loadedFileRef:FileReference = event.target; + trace('Loaded file: ' + loadedFileRef.name); + onOpen(loadedFileRef.data); + } + + var onFileSelected = function(event) + { + var selectedFileRef:FileReference = event.target; + trace('Selected file: ' + selectedFileRef.name); + selectedFileRef.addEventListener(Event.COMPLETE, onFileLoaded); + selectedFileRef.load(); + } + + var fileRef = new FileReference(); + file.addEventListener(Event.SELECT, onFileSelected); + file.open(filter, defaultPath, dialogTitle); + #else + onCancel(); + return false; + #end + } + + /** + * Browses for a single file location, then writes the provided `haxe.io.Bytes` data and calls `onSave(path)` when done. + * Works great on desktop and HTML5. + * + * @param typeFilter TODO What does this do? + * @return Whether the file dialog was opened successfully. + */ + public static function saveFile(data:Bytes, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String, ?dialogTitle:String):Bool + { + #if desktop + var filter = defaultFileName != null ? Path.extension(defaultFileName) : null; + + var fileDialog = new FileDialog(); + if (onSave != null) + fileDialog.onSelect.add(onSave); + if (onCancel != null) + fileDialog.onCancel.add(onCancel); + + fileDialog.save(data, filter, defaultFileName, dialogTitle); + return true; + #elseif html5 + var filter = defaultFileName != null ? Path.extension(defaultFileName) : null; + + var fileDialog = new FileDialog(); + if (onSave != null) + fileDialog.onSave.add(onSave); + if (onCancel != null) + fileDialog.onCancel.add(onCancel); + + fileDialog.save(data, filter, defaultFileName, dialogTitle); + #else + onCancel(); + return false; + #end + } + + /** + * Prompts the user to save multiple files. + * On desktop, this will prompt the user for a directory, then write all of the files to there. + * On HTML5, this will zip the files up and prompt the user to save that. + * + * @param typeFilter TODO What does this do? + * @return Whether the file dialog was opened successfully. + */ + public static function saveMultipleFiles(resources:Array, ?onSaveAll:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + ?force:Bool = false):Bool + { + #if desktop + // Prompt the user for a directory, then write all of the files to there. + var onSelectDir = function(targetPath:String) + { + var paths:Array = []; + for (resource in resources) + { + var filePath = haxe.io.Path.join([targetPath, resource.fileName]); + try + { + if (resource.data == null) + { + trace('WARNING: File $filePath has no data or content. Skipping.'); + continue; + } + else + { + writeBytesToPath(filePath, resource.data, force ? Force : Skip); + } + } + catch (e:Dynamic) + { + trace('Failed to write file (probably already exists): $filePath' + filePath); + continue; + } + paths.push(filePath); + } + onSaveAll(paths); + } + + browseForDirectory(null, onSelectDir, onCancel, defaultPath, "Choose directory to save all files to..."); + + return true; + #elseif html5 + saveFilesAsZIP(resources, onSaveAll, onCancel, defaultPath, force); + + return true; + #else + onCancel(); + return false; + #end + } + + /** + * Takes an array of file entries and prompts the user to save them as a ZIP file. + */ + public static function saveFilesAsZIP(resources:Array, ?onSave:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + ?force:Bool = false):Bool + { + // Create a ZIP file. + var zipBytes = createZIPFromEntries(resources); + + var onSave = function(path:String) + { + onSave([path]); + }; + + // Prompt the user to save the ZIP file. + saveFile(zipBytes, onSave, onCancel, defaultPath, "Save files as ZIP..."); + + return true; + } + + /** + * Takes an array of file entries and forcibly writes a ZIP to the given path. + * Only works on desktop, because HTML5 doesn't allow you to write files to arbitrary paths. + * Use `saveFilesAsZIP` instead. + * @param force Whether to force overwrite an existing file. + */ + public static function saveFilesAsZIPToPath(resources:Array, path:String, ?force:Bool = false):Bool + { + #if desktop + // Create a ZIP file. + var zipBytes = createZIPFromEntries(resources); + + // Write the ZIP. + writeBytesToPath(path, zipBytes, force ? Force : Skip); + + return true; + #else + return false; + #end + } + + /** + * Write string file contents directly to a given path. + * Only works on desktop. + * + * @param mode Whether to Force, Skip, or Ask to overwrite an existing file. + */ + public static function writeStringToPath(path:String, data:String, mode:FileWriteMode = Skip) + { + #if sys + createDirIfNotExists(Path.directory(path)); + + switch (mode) + { + case Force: + sys.io.File.saveContent(path, data); + case Skip: + if (!sys.FileSystem.exists(path)) + { + sys.io.File.saveContent(path, data); + } + else + { + throw 'File already exists: $path'; + } + case Ask: + if (sys.FileSystem.exists(path)) + { + // TODO: We don't have the technology to use native popups yet. + } + else + { + sys.io.File.saveContent(path, data); + } + } + #else + throw 'Direct file writing by path not supported on this platform.'; + #end + } + + /** + * Write byte file contents directly to a given path. + * Only works on desktop. + * + * @param mode Whether to Force, Skip, or Ask to overwrite an existing file. + */ + public static function writeBytesToPath(path:String, data:Bytes, mode:FileWriteMode = Skip) + { + #if sys + createDirIfNotExists(Path.directory(path)); + + switch (mode) + { + case Force: + sys.io.File.saveBytes(path, data); + case Skip: + if (!sys.FileSystem.exists(path)) + { + sys.io.File.saveBytes(path, data); + } + else + { + throw 'File already exists: $path'; + } + case Ask: + if (sys.FileSystem.exists(path)) + { + // TODO: We don't have the technology to use native popups yet. + } + else + { + sys.io.File.saveBytes(path, data); + } + } + #else + throw 'Direct file writing by path not supported on this platform.'; + #end + } + + /** + * Write string file contents directly to the end of a file at the given path. + * Only works on desktop. + */ + public static function appendStringToPath(path:String, data:String) + { + sys.io.File.append(path, false).writeString(data); + } + + /** + * Create a directory if it doesn't already exist. + * Only works on desktop. + */ + public static function createDirIfNotExists(dir:String) + { + #if sys + if (!sys.FileSystem.exists(dir)) + { + sys.FileSystem.createDirectory(dir); + } + #end + } + + static var tempDir:String = null; + static final TEMP_ENV_VARS:Array = ['TEMP', 'TMPDIR', 'TEMPDIR', 'TMP']; + + /** + * Get the path to a temporary directory we can use for writing files. + * Only works on desktop. + */ + public static function getTempDir():String + { + if (tempDir != null) + return tempDir; + + #if sys + #if windows + var path:String = null; + + for (envName in TEMP_ENV_VARS) + { + path = Sys.getEnv(envName); + + if (path == "") + path = null; + if (path != null) + break; + } + + return tempDir = Path.join([path, 'funkin/']); + #else + return tempDir = '/tmp/funkin/'; + #end + #else + return null; + #end + } + + /** + * Create a Bytes object containing a ZIP file, containing the provided entries. + * + * @param entries The entries to add to the ZIP file. + * @return The ZIP file as a Bytes object. + */ + public static function createZIPFromEntries(entries:Array):Bytes + { + var o = new haxe.io.BytesOutput(); + + var zipWriter = new haxe.zip.Writer(o); + zipWriter.write(entries.list()); + + return o.getBytes(); + } + + /** + * Create a ZIP file entry from a file name and its string contents. + * + * @param name The name of the file. You can use slashes to create subdirectories. + * @param content The string contents of the file. + * @return The resulting entry. + */ + public static function makeZIPEntry(name:String, content:String):Entry + { + var data = haxe.io.Bytes.ofString(content, UTF8); + + return { + fileName: name, + fileSize: data.length, + + data: data, + dataSize: data.length, + + compressed: false, + + fileTime: Date.now(), + crc32: null, + extraFields: null, + }; + } + + static function convertTypeFilter(typeFilter:Array):String + { + var filter = null; + if (typeFilter != null) + { + var filters = []; + for (type in typeFilter) + { + filters.push(StringTools.replace(StringTools.replace(type.extension, "*.", ""), ";", ",")); + } + filter = filters.join(";"); + } + + return filter; + } +} + +enum FileWriteMode +{ + /** + * Forcibly overwrite the file if it already exists. + */ + Force; + + /** + * Ask the user if they want to overwrite the file if it already exists. + */ + Ask; + + /** + * Skip the file if it already exists. + */ + Skip; +} diff --git a/source/funkin/util/SaveDataUtil.hx b/source/funkin/util/SaveDataUtil.hx new file mode 100644 index 000000000..457fd2f0d --- /dev/null +++ b/source/funkin/util/SaveDataUtil.hx @@ -0,0 +1,2 @@ +package funkin.util; + diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx index fe27ed252..bf80e688f 100644 --- a/source/funkin/util/WindowUtil.hx +++ b/source/funkin/util/WindowUtil.hx @@ -1,5 +1,7 @@ package funkin.util; +import flixel.util.FlxSignal.FlxTypedSignal; + class WindowUtil { public static function openURL(targetUrl:String) @@ -12,7 +14,23 @@ class WindowUtil FlxG.openURL(targetUrl); #end #else - trace('Cannot open') + trace('Cannot open'); #end } + + /** + * 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. + + // onExit is called when the game window is closed. + openfl.Lib.current.stage.application.onExit.add(function(exitCode:Int) + { + windowExit.dispatch(exitCode); + }); + } } diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx index ee0699810..3e95e92c3 100644 --- a/source/funkin/util/assets/DataAssets.hx +++ b/source/funkin/util/assets/DataAssets.hx @@ -1,7 +1,5 @@ package funkin.util.assets; -using StringTools; - class DataAssets { static function buildDataPath(path:String):String diff --git a/source/funkin/util/IteratorTools.hx b/source/funkin/util/tools/IteratorTools.hx similarity index 92% rename from source/funkin/util/IteratorTools.hx rename to source/funkin/util/tools/IteratorTools.hx index 259b75a09..0b25a7df5 100644 --- a/source/funkin/util/IteratorTools.hx +++ b/source/funkin/util/tools/IteratorTools.hx @@ -1,4 +1,4 @@ -package funkin.util; +package funkin.util.tools; /** * A static extension which provides utility functions for Iterators. diff --git a/source/funkin/util/tools/StringTools.hx b/source/funkin/util/tools/StringTools.hx new file mode 100644 index 000000000..0c6937e94 --- /dev/null +++ b/source/funkin/util/tools/StringTools.hx @@ -0,0 +1,59 @@ +package funkin.util.tools; + +/** + * A static extension which provides utility functions for Strings. + */ +class StringTools +{ + /** + * Converts a string to title case. For example, "hello world" becomes "Hello World". + * + * @param value The string to convert. + * @return The converted string. + */ + public static function toTitleCase(value:String):String + { + var words:Array = value.split(" "); + var result:String = ""; + for (i in 0...words.length) + { + var word:String = words[i]; + result += word.charAt(0).toUpperCase() + word.substr(1).toLowerCase(); + if (i < words.length - 1) + { + result += " "; + } + } + return result; + } + + /** + * Converts a string to lower kebab case. For example, "Hello World" becomes "hello-world". + * + * @param value The string to convert. + * @return The converted string. + */ + public static function toLowerKebabCase(value:String):String { + return value.toLowerCase().replace(' ', "-"); + } + + /** + * Converts a string to upper kebab case, aka screaming kebab case. For example, "Hello World" becomes "HELLO-WORLD". + * + * @param value The string to convert. + * @return The converted string. + */ + public static function toUpperKebabCase(value:String):String { + return value.toUpperCase().replace(' ', "-"); + } + + /** + * Parses the string data as JSON and returns the resulting object. + * + * @return The parsed object. + */ + public static function parseJSON(value:String):Dynamic + { + return SerializerUtil.fromJSON(value); + } +}