From b3b7fb49c24c08eef3c0cd6657f3954e8dd9a38a Mon Sep 17 00:00:00 2001 From: EliteMasterEric Date: Sat, 17 Dec 2022 15:19:42 -0500 Subject: [PATCH] Difficulty selector and player previews --- docs/style-guide.md | 54 ++ hmm.json | 4 +- source/funkin/Conductor.hx | 20 +- source/funkin/LoadingState.hx | 2 +- source/funkin/MusicBeatState.hx | 5 +- source/funkin/import.hx | 7 + source/funkin/modding/module/ModuleHandler.hx | 2 - source/funkin/play/character/BaseCharacter.hx | 5 +- source/funkin/play/character/CharacterData.hx | 2 +- source/funkin/play/song/Song.hx | 7 +- source/funkin/play/song/SongData.hx | 17 +- source/funkin/play/song/SongDataUtils.hx | 2 +- source/funkin/play/song/SongSerializer.hx | 8 +- source/funkin/play/stage/Bopper.hx | 12 +- source/funkin/play/stage/StageData.hx | 2 +- .../charting/ChartEditorDialogHandler.hx | 37 +- .../ui/debug/charting/ChartEditorState.hx | 582 ++++++++++-------- .../debug/charting/ChartEditorThemeHandler.hx | 8 +- .../charting/ChartEditorToolboxHandler.hx | 277 ++++++++- source/funkin/ui/haxeui/HaxeUIState.hx | 82 +++ .../ui/haxeui/components/CharacterPlayer.hx | 276 +++++++++ .../ui/haxeui/components/SparrowImage.hx | 8 - source/funkin/util/FileUtil.hx | 381 ++++++++++++ source/funkin/util/SaveDataUtil.hx | 2 + source/funkin/util/SystemUtil.hx | 389 ------------ .../funkin/util/{ => tools}/IteratorTools.hx | 2 +- source/funkin/util/tools/StringTools.hx | 59 ++ 27 files changed, 1539 insertions(+), 713 deletions(-) create mode 100644 docs/style-guide.md create mode 100644 source/funkin/ui/haxeui/components/CharacterPlayer.hx delete mode 100644 source/funkin/ui/haxeui/components/SparrowImage.hx create mode 100644 source/funkin/util/FileUtil.hx create mode 100644 source/funkin/util/SaveDataUtil.hx delete mode 100644 source/funkin/util/SystemUtil.hx rename source/funkin/util/{ => tools}/IteratorTools.hx (92%) create mode 100644 source/funkin/util/tools/StringTools.hx 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 ef45ddbc4..07aa6090d 100644 --- a/hmm.json +++ b/hmm.json @@ -42,14 +42,14 @@ "name": "haxeui-core", "type": "git", "dir": null, - "ref": "fc8d656b", + "ref": "82fde3a", "url": "https://github.com/haxeui/haxeui-core/" }, { "name": "haxeui-flixel", "type": "git", "dir": null, - "ref": "80941a7", + "ref": "f7b403a", "url": "https://github.com/haxeui/haxeui-flixel" }, { diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index 583955bf6..425ce25ae 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -116,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() { @@ -151,7 +164,10 @@ class Conductor */ public static function forceBPM(?bpm:Float = null) { - trace('[CONDUCTOR] Forcing BPM to ' + bpm); + if (bpm != null) + trace('[CONDUCTOR] Forcing BPM to ' + bpm); + else + trace('[CONDUCTOR] Resetting BPM to default'); Conductor.bpmOverride = bpm; } diff --git a/source/funkin/LoadingState.hx b/source/funkin/LoadingState.hx index 3c60d34df..4b4f38016 100644 --- a/source/funkin/LoadingState.hx +++ b/source/funkin/LoadingState.hx @@ -351,5 +351,5 @@ class MultiCallback return fired.copy(); public function getUnfired() - return [for (id in unfired.keys()) id]; + return unfired.array(); } diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx index b8d27dffb..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,9 @@ 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/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/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/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx index ca708da8d..eb42e506d 100644 --- a/source/funkin/play/character/BaseCharacter.hx +++ b/source/funkin/play/character/BaseCharacter.hx @@ -96,8 +96,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..23a800109 100644 --- a/source/funkin/play/character/CharacterData.hx +++ b/source/funkin/play/character/CharacterData.hx @@ -225,7 +225,7 @@ class CharacterDataParser public static function listCharacterIds():Array { - return [for (x in characterCache.keys()) x]; + return characterCache.keys().array(); } static function clearCharacterCache():Void diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 1b24261f4..08ce6818f 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -127,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); } @@ -219,7 +222,7 @@ class SongDifficulty public function getPlayableChars():Array { - return [for (i in chars.keys()) i]; + return chars.keys().array(); } public function getEvents():Array diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx index 05c389b9e..b647d7394 100644 --- a/source/funkin/play/song/SongData.hx +++ b/source/funkin/play/song/SongData.hx @@ -116,7 +116,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 +261,24 @@ 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..0b5a97ce0 100644 --- a/source/funkin/play/stage/StageData.hx +++ b/source/funkin/play/stage/StageData.hx @@ -143,7 +143,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/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx index 7440b47aa..0b49dd201 100644 --- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx @@ -1,17 +1,17 @@ package funkin.ui.debug.charting; -import funkin.play.character.CharacterData.CharacterDataParser; -import funkin.play.character.BaseCharacter; -import haxe.ui.components.Label; -import haxe.ui.events.MouseEvent; -import funkin.play.song.SongData.SongPlayableChar; 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; @@ -22,6 +22,7 @@ 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; @@ -110,6 +111,8 @@ class ChartEditorDialogHandler } // 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) -> { @@ -126,9 +129,33 @@ class ChartEditorDialogHandler // 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; } diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index c5293b844..39d1b34c3 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -1,12 +1,8 @@ package funkin.ui.debug.charting; -import funkin.play.song.SongData.SongDataParser; -import funkin.play.song.Song; -import lime.media.AudioBuffer; -import funkin.input.Cursor; -import flixel.FlxSprite; 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; @@ -15,8 +11,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; @@ -25,24 +27,28 @@ 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.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 openfl.display.BitmapData; import openfl.geom.Rectangle; @@ -77,7 +83,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_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; @@ -214,32 +224,47 @@ class ChartEditorState extends HaxeUIState /** * songLength, converted to steps. + * TODO: Handle BPM changes. */ - var songLengthInSteps(get, null):Float; + var songLengthInSteps(get, set):Float; function get_songLengthInSteps():Float { return songLengthInPixels / GRID_SIZE; } + function set_songLengthInSteps(value:Float):Float + { + songLengthInPixels = Std.int(value * GRID_SIZE); + return value; + } + /** * songLength, converted to milliseconds. + * TODO: Handle BPM changes. */ - var songLengthInMs(get, 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; } @@ -262,7 +287,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 = ''; @@ -276,6 +300,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. */ @@ -402,11 +436,17 @@ 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 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; /** @@ -468,9 +508,17 @@ class ChartEditorState extends HaxeUIState /** * The audio track for the vocals. - * TODO: Replace with a VocalSoundGroup. */ - var audioVocalTrackGroup:VoicesGroup; + 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 @@ -661,6 +709,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 @@ -811,7 +866,7 @@ class ChartEditorState extends HaxeUIState // Initialize the song chart data. songChartData = new Map(); - audioVocalTrackGroup = new VoicesGroup(); + audioVocalTrackGroup = new VocalGroup(); } /** @@ -839,7 +894,7 @@ class ChartEditorState extends HaxeUIState add(gridTiledSprite); gridGhostNote = new ChartEditorNoteSprite(this); - gridGhostNote.alpha = 0.8; + gridGhostNote.alpha = 0.6; gridGhostNote.noteData = new SongNoteData(-1, -1, 0, ""); gridGhostNote.visible = false; add(gridGhostNote); @@ -947,17 +1002,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); @@ -1018,6 +1075,7 @@ 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()); @@ -1075,37 +1133,43 @@ class ChartEditorState extends HaxeUIState addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> ChartEditorDialogHandler.openUserGuideDialog(this)); - addUIChangeListener('menubarItemToggleSidebar', (event:UIEvent) -> - { - var sidebar:Component = findComponent('sidebar', Component); - - sidebar.visible = event.value; - }); - setUISelected('menubarItemToggleSidebar', true); - 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; }); - setUISelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer); + setUICheckboxSelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer); addUIChangeListener('menubarItemOpponentHitsounds', (event:UIEvent) -> { hitsoundsEnabledOpponent = event.value; }); - setUISelected('menubarItemOpponentHitsounds', hitsoundsEnabledOpponent); + setUICheckboxSelected('menubarItemOpponentHitsounds', hitsoundsEnabledOpponent); var instVolumeLabel:Label = findComponent('menubarLabelVolumeInstrumental', Label); addUIChangeListener('menubarItemVolumeInstrumental', (event:UIEvent) -> @@ -1142,8 +1206,7 @@ class ChartEditorState extends HaxeUIState { 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); @@ -1152,66 +1215,34 @@ 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')); } public override function update(elapsed:Float) { + // dispatchEvent gets called here. super.update(elapsed); FlxG.mouse.visible = true; @@ -1222,12 +1253,12 @@ class ChartEditorState extends HaxeUIState // These ones only happen if the modal dialog is not open. handleScrollKeybinds(); - handleZoom(); - handleSnap(); + // handleZoom(); + // handleSnap(); handleCursor(); handleMenubar(); - handleSidebar(); + handleToolboxes(); handlePlaybar(); handlePlayhead(); @@ -1247,6 +1278,16 @@ class ChartEditorState extends HaxeUIState ChartEditorDialogHandler.openWelcomeDialog(this, true); } + if (FlxG.keys.justPressed.W) + { + difficultySelectDirty = true; + } + + if (FlxG.keys.justPressed.E) + { + currentSongMetadata.timeChanges[0].timeSignatureNum = (currentSongMetadata.timeChanges[0].timeSignatureNum == 4 ? 3 : 4); + } + // Right align the BF health icon. // Base X position to the right of the grid. @@ -1261,6 +1302,7 @@ class ChartEditorState extends HaxeUIState */ override function beatHit():Bool { + // dispatchEvent gets called here. if (!super.beatHit()) return false; @@ -1277,6 +1319,7 @@ class ChartEditorState extends HaxeUIState */ override function stepHit():Bool { + // dispatchEvent gets called here. if (!super.stepHit()) return false; @@ -1757,7 +1800,7 @@ class ChartEditorState extends HaxeUIState if (cursorColumn == eventColumn) { // Create an event and place it in the chart. - // TODO: Allow configuring the event to place from the sidebar. + // TODO: Allow configuring the event to place. var newEventData:SongEventData = new SongEventData(cursorMs, "test", {}); performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL)); @@ -1810,16 +1853,12 @@ class ChartEditorState extends HaxeUIState // Indicate that we can pla gridGhostNote.visible = (cursorColumn != eventColumn); - if (cursorColumn != gridGhostNote.noteData.data || selectedNoteKind != gridGhostNote.noteData.kind) { + if (cursorColumn != gridGhostNote.noteData.data || selectedNoteKind != gridGhostNote.noteData.kind) + { gridGhostNote.noteData.kind = selectedNoteKind; gridGhostNote.noteData.data = cursorColumn; gridGhostNote.playNoteAnimation(); } - - FlxG.watch.addQuick("cursorY", cursorY); - FlxG.watch.addQuick("cursorFractionalStep", cursorFractionalStep); - FlxG.watch.addQuick("cursorStep", cursorStep); - FlxG.watch.addQuick("cursorMs", cursorMs); gridGhostNote.noteData.time = cursorMs; gridGhostNote.updateNotePosition(renderedNotes); @@ -2004,6 +2043,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; @@ -2140,9 +2183,6 @@ class ChartEditorState extends HaxeUIState */ function handleViewKeybinds() { - // B = Toggle Sidebar - if (FlxG.keys.justPressed.B) - toggleSidebar(); } /** @@ -2155,68 +2195,129 @@ class ChartEditorState extends HaxeUIState 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; @@ -2264,6 +2365,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. */ @@ -2432,6 +2545,23 @@ class ChartEditorState extends HaxeUIState 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 @@ -2575,20 +2705,6 @@ class ChartEditorState extends HaxeUIState return this.playheadPositionInPixels; } - /** - * Show the sidebar if it's hidden, or hide it if it's shown. - */ - function toggleSidebar() - { - var sidebar:Component = findComponent('sidebar', Component); - - // Set visibility while syncing the checkbox. - if (sidebar != null) - { - sidebar.visible = setUISelected('menubarItemToggleSidebar', !sidebar.visible); - } - } - /** * Loads an instrumental from an absolute file path, replacing the current instrumental. */ @@ -2622,8 +2738,8 @@ class ChartEditorState extends HaxeUIState public function loadInstrumentalFromAsset(path:String):Void { - var vocalTrack = FlxG.sound.load(path, 1.0, false); - audioInstTrack = vocalTrack; + var instTrack = FlxG.sound.load(path, 1.0, false); + audioInstTrack = instTrack; postLoadInstrumental(); } @@ -2639,7 +2755,7 @@ class ChartEditorState extends HaxeUIState audioVocalTrackGroup.pause(); }; - songLengthInPixels = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * GRID_SIZE); + songLengthInMs = audioInstTrack.length; gridTiledSprite.height = songLengthInPixels; if (gridSpectrogram != null) @@ -2668,7 +2784,7 @@ class ChartEditorState extends HaxeUIState public function loadVocalsFromAsset(path:String):Void { - var vocalTrack = FlxG.sound.load(path, 1.0, false); + var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false); audioVocalTrackGroup.add(vocalTrack); } @@ -2679,7 +2795,7 @@ class ChartEditorState extends HaxeUIState { var openflSound = new openfl.media.Sound(); openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); - var vocalTrack = FlxG.sound.load(openflSound, 1.0, false); + var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false); audioVocalTrackGroup.add(vocalTrack); // Tell the user the load was successful. @@ -2747,85 +2863,9 @@ class ChartEditorState extends HaxeUIState 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. */ @@ -2958,39 +2998,49 @@ class ChartEditorState extends HaxeUIState notifBar.hide(); } - /** - * 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. - */ - function promptSaveChanges(onComplete:Void->Void, ?onCancel:Void->Void):Void + public function exportAllSongData():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...'); + + // Prompt and save. + var onSave:Array->Void = (paths:Array) -> + { + trace('Successfully exported files.'); + }; + + var onCancel:Void->Void = () -> + { + trace('Export cancelled.'); + }; + + FileUtil.saveFilesAsZIP(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 290460580..317f4c632 100644 --- a/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx @@ -62,10 +62,6 @@ 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; @@ -112,7 +108,7 @@ 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:Int = Std.int(ChartEditorState.GRID_SIZE * (ChartEditorState.STRUMLINE_SIZE * 2 + 1)); - var gridHeight:Int = Std.int(ChartEditorState.GRID_SIZE * (STEPS_PER_BEAT * BEATS_PER_MEASURE)); + 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); @@ -130,7 +126,7 @@ class ChartEditorThemeHandler selectionBorderColor); // Selection borders in the middle. - for (i in 1...(STEPS_PER_BEAT * BEATS_PER_MEASURE)) + 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), 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/ui/haxeui/components/SparrowImage.hx b/source/funkin/ui/haxeui/components/SparrowImage.hx deleted file mode 100644 index b71da0e82..000000000 --- a/source/funkin/ui/haxeui/components/SparrowImage.hx +++ /dev/null @@ -1,8 +0,0 @@ -package funkin.ui.haxeui.components; - -import haxe.ui.components.Image; - -class SparrowImage extends Image -{ - // -} diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx new file mode 100644 index 000000000..4685ec1d9 --- /dev/null +++ b/source/funkin/util/FileUtil.hx @@ -0,0 +1,381 @@ +package funkin.util; + +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); + } + } 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; + } + + /** + * Write string file contents directly to a given path. + * Only works on desktop. + */ + public static function writeStringToPath(path:String, data:String, force:Bool = false) + { + if (force || !sys.FileSystem.exists(path)) + { + sys.io.File.saveContent(path, data); + } + else + { + throw 'File already exists: $path'; + } + } + + /** + * Write byte file contents directly to a given path. + * Only works on desktop. + */ + public static function writeBytesToPath(path:String, data:Bytes, force:Bool = false) + { + if (force || !sys.FileSystem.exists(path)) + { + sys.io.File.saveBytes(path, data); + } + else + { + throw 'File already exists: $path'; + } + } + + /** + * 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 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; + } +} 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/SystemUtil.hx b/source/funkin/util/SystemUtil.hx deleted file mode 100644 index cccd54164..000000000 --- a/source/funkin/util/SystemUtil.hx +++ /dev/null @@ -1,389 +0,0 @@ -package funkin.util; - -import haxe.io.Path; -import haxe.io.Bytes; -import haxe.io.BytesOutput; -import haxe.io.Eof; -import haxe.zip.Entry; -import haxe.zip.Writer; -import haxe.Json; -import haxe.Template; -import sys.io.File; -import sys.io.Process; -import sys.FileSystem; - -class SystemUtil -{ - public static var hostArchitecture(get, never):HostArchitecture; - public static var hostPlatform(get, never):HostPlatform; - public static var processorCores(get, never):Int; - private static var _hostArchitecture:HostArchitecture; - private static var _hostPlatform:HostPlatform; - private static var _processorCores:Int = 0; - - private static function get_hostPlatform():HostPlatform - { - if (_hostPlatform == null) - { - if (new EReg("window", "i").match(Sys.systemName())) - { - _hostPlatform = WINDOWS; - } - else if (new EReg("linux", "i").match(Sys.systemName())) - { - _hostPlatform = LINUX; - } - else if (new EReg("mac", "i").match(Sys.systemName())) - { - _hostPlatform = MAC; - } - - trace("", " - \x1b[1mDetected host platform:\x1b[0m " + Std.string(_hostPlatform).toUpperCase()); - } - - return _hostPlatform; - } - - private static function get_hostArchitecture():HostArchitecture - { - if (_hostArchitecture == null) - { - switch (hostPlatform) - { - case WINDOWS: - var architecture = Sys.getEnv("PROCESSOR_ARCHITECTURE"); - var wow64Architecture = Sys.getEnv("PROCESSOR_ARCHITEW6432"); - - if (architecture.indexOf("64") > -1 || wow64Architecture != null && wow64Architecture.indexOf("64") > -1) - { - _hostArchitecture = X64; - } - else - { - _hostArchitecture = X86; - } - - case LINUX, MAC: - #if nodejs - switch (js.Node.process.arch) - { - case "arm": - _hostArchitecture = ARMV7; - - case "x64": - _hostArchitecture = X64; - - default: - _hostArchitecture = X86; - } - #else - var process = new Process("uname", ["-m"]); - var output = process.stdout.readAll().toString(); - var error = process.stderr.readAll().toString(); - process.exitCode(); - process.close(); - - if (output.indexOf("armv6") > -1) - { - _hostArchitecture = ARMV6; - } - else if (output.indexOf("armv7") > -1) - { - _hostArchitecture = ARMV7; - } - else if (output.indexOf("64") > -1) - { - _hostArchitecture = X64; - } - else - { - _hostArchitecture = X86; - } - #end - - default: - _hostArchitecture = ARMV6; - } - - trace("", " - \x1b[1mDetected host architecture:\x1b[0m " + Std.string(_hostArchitecture).toUpperCase()); - } - - return _hostArchitecture; - } - - private static function get_processorCores():Int - { - if (_processorCores < 1) - { - var result = null; - - if (hostPlatform == WINDOWS) - { - var env = Sys.getEnv("NUMBER_OF_PROCESSORS"); - - if (env != null) - { - result = env; - } - } - else if (hostPlatform == LINUX) - { - result = runProcess("", "nproc", null, true, true, true); - - if (result == null) - { - var cpuinfo = runProcess("", "cat", ["/proc/cpuinfo"], true, true, true); - - if (cpuinfo != null) - { - var split = cpuinfo.split("processor"); - result = Std.string(split.length - 1); - } - } - } - else if (hostPlatform == MAC) - { - var cores = ~/Total Number of Cores: (\d+)/; - var output = runProcess("", "/usr/sbin/system_profiler", ["-detailLevel", "full", "SPHardwareDataType"]); - - if (cores.match(output)) - { - result = cores.matched(1); - } - } - - if (result == null || Std.parseInt(result) < 1) - { - _processorCores = 1; - } - else - { - _processorCores = Std.parseInt(result); - } - } - - return _processorCores; - } - - public static function runProcess(path:String, command:String, args:Array = null, waitForOutput:Bool = true, safeExecute:Bool = true, - ignoreErrors:Bool = false, print:Bool = false, returnErrorValue:Bool = false):String - { - if (print) - { - var message = command; - - if (args != null) - { - for (arg in args) - { - if (arg.indexOf(" ") > -1) - { - message += " \"" + arg + "\""; - } - else - { - message += " " + arg; - } - } - } - - Sys.println(message); - } - - #if (haxe_ver < "3.3.0") - command = Path.escape(command); - #end - - if (safeExecute) - { - try - { - if (path != null - && path != "" - && !FileSystem.exists(FileSystem.fullPath(path)) - && !FileSystem.exists(FileSystem.fullPath(new Path(path).dir))) - { - trace("The specified target path \"" + path + "\" does not exist"); - } - - return _runProcess(path, command, args, waitForOutput, safeExecute, ignoreErrors, returnErrorValue); - } - catch (e:Dynamic) - { - if (!ignoreErrors) - { - trace("", e); - } - - return null; - } - } - else - { - return _runProcess(path, command, args, waitForOutput, safeExecute, ignoreErrors, returnErrorValue); - } - } - - private static function _runProcess(path:String, command:String, args:Null>, waitForOutput:Bool, safeExecute:Bool, ignoreErrors:Bool, - returnErrorValue:Bool):String -{ - var oldPath:String = ""; - - if (path != null && path != "") - { - trace("", " - \x1b[1mChanging directory:\x1b[0m " + path + ""); - - oldPath = Sys.getCwd(); - Sys.setCwd(path); - } - - var argString = ""; - - if (args != null) - { - for (arg in args) - { - if (arg.indexOf(" ") > -1) - { - argString += " \"" + arg + "\""; - } - else - { - argString += " " + arg; - } - } - } - - trace("", " - \x1b[1mRunning process:\x1b[0m " + command + argString); - - var output = ""; - var result = 0; - - var process:Process; - - if (args != null && args.length > 0) - { - process = new Process(command, args); - } - else - { - process = new Process(command); - } - - if (waitForOutput) - { - var buffer = new BytesOutput(); - var waiting = true; - - while (waiting) - { - try - { - var current = process.stdout.readAll(1024); - buffer.write(current); - - if (current.length == 0) - { - waiting = false; - } - } - catch (e:Eof) - { - waiting = false; - } - } - - result = process.exitCode(); - - output = buffer.getBytes().toString(); - - if (output == "") - { - var error = process.stderr.readAll().toString(); - process.close(); - - if (result != 0 || error != "") - { - if (ignoreErrors) - { - output = error; - } - else if (!safeExecute) - { - throw error; - } - else - { - trace(error); - } - - if (returnErrorValue) - { - return output; - } - else - { - return null; - } - } - - /*if (error != "") { - trace (error); - }*/ - } - else - { - process.close(); - } - } - - if (oldPath != "") - { - Sys.setCwd(oldPath); - } - - return output; -} - - public static function getTempDirectory(extension:String = ""):String - { - #if (flash || html5) - return null; - #else - var path = ""; - - if (hostPlatform == WINDOWS) - { - path = Sys.getEnv("TEMP"); - } - else - { - path = Sys.getEnv("TMPDIR"); - - if (path == null) - { - path = "/tmp"; - } - } - - path = Path.join([path, "Funkin"]); - - return path; - #end - } -} - -enum HostArchitecture -{ - ARMV6; - ARMV7; - X86; - X64; -} - -@:enum abstract HostPlatform(String) from String to String -{ - public var WINDOWS = "windows"; - public var MAC = "mac"; - public var LINUX = "linux"; -} 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); + } +}