diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml index 38a504442..756530178 100644 --- a/.github/actions/setup-haxeshit/action.yml +++ b/.github/actions/setup-haxeshit/action.yml @@ -3,18 +3,31 @@ description: "sets up haxe shit, using HMM!" runs: using: "composite" steps: - - uses: krdlab/setup-haxe@v1.5.1 - with: - haxe-version: 4.3.1 - - name: Config haxelib - run: | - haxelib config - shell: bash - - name: Installing Haxe lol - run: | - haxe -version - haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git development - haxelib version - haxelib --global install hmm - haxelib --global run hmm install --quiet - shell: bash + - uses: krdlab/setup-haxe@v1.5.1 + with: + haxe-version: 4.3.1 + - name: Config haxelib + run: | + haxelib config + shell: bash + - name: Installing Haxe lol + run: | + haxe -version + haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git development + haxelib version + haxelib --global install hmm + shell: bash + - name: dependency install cache + id: cache-hmm + uses: actions/cache@v3 + with: + path: .haxelib + key: ${{ runner.os }}-hmm-${{ hashFiles('**/hmm.json') }} + restore-keys: | + ${{ runner.os }}-hmm- + ${{ runner.os }}- + - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }} + name: hmm install + run: | + haxelib --global run hmm install + shell: bash diff --git a/.github/hooks/README.md b/.github/hooks/README.md new file mode 100644 index 000000000..544fbf365 --- /dev/null +++ b/.github/hooks/README.md @@ -0,0 +1,5 @@ +# Git Hooks +These work even on Windows because of Git Bash. + +## Setup +`git config core.hooksPath .github/hooks` diff --git a/.github/hooks/post-checkout b/.github/hooks/post-checkout new file mode 100644 index 000000000..12358c998 --- /dev/null +++ b/.github/hooks/post-checkout @@ -0,0 +1,2 @@ +#!/bin/sh +git submodule update --init --recursive diff --git a/.github/hooks/post-merge b/.github/hooks/post-merge new file mode 100644 index 000000000..12358c998 --- /dev/null +++ b/.github/hooks/post-merge @@ -0,0 +1,2 @@ +#!/bin/sh +git submodule update --init --recursive diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push new file mode 100644 index 000000000..ec4c820ac --- /dev/null +++ b/.github/hooks/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +if git diff --cached --submodule | grep -q "^+"; then + echo "WARNING: You have unpushed changes in submodules." + exit 1 +fi diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml index 0d9f1f2a4..3ce0d538b 100644 --- a/.github/workflows/build-shit.yml +++ b/.github/workflows/build-shit.yml @@ -30,6 +30,7 @@ jobs: - uses: ./.github/actions/setup-haxeshit - name: Build game run: | + sudo apt-get update sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev haxelib run lime build html5 -release --times ls @@ -60,18 +61,19 @@ jobs: butler-key: ${{ secrets.BUTLER_API_KEY}} build-dir: export/release/windows/bin target: win - test-unit-win: - needs: create-nightly-win - runs-on: windows-latest - permissions: - contents: write - actions: write - steps: - - uses: actions/checkout@v3 - with: - submodules: 'recursive' - - uses: ./.github/actions/setup-haxeshit - - name: Run unit tests - run: | - cd ./tests/unit/ - ./start-win-native.bat +# test-unit-win: +# needs: create-nightly-win +# runs-on: windows-latest +# permissions: +# contents: write +# actions: write +# steps: +# - uses: actions/checkout@v3 +# with: +# submodules: 'recursive' +# token: ${{ secrets.GH_RO_PAT }} +# - uses: ./.github/actions/setup-haxeshit +# - name: Run unit tests +# run: | +# cd ./tests/unit/ +# ./start-win-native.bat diff --git a/assets b/assets index a62e7e50d..7bc9407e0 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit a62e7e50d59c14d256c75da651b79dea77e1620e +Subproject commit 7bc9407e0e8141a643605ff4514ba63169cc41e2 diff --git a/hmm.json b/hmm.json index 47460facf..aa032fb75 100644 --- a/hmm.json +++ b/hmm.json @@ -97,8 +97,8 @@ "name": "json2object", "type": "git", "dir": null, - "ref": "429986134031cbb1980f09d0d3d642b4b4cbcd6a", - "url": "https://github.com/elnabo/json2object" + "ref": "f4df19cfa196f85eece55c3367021fc965f1fa9a", + "url": "https://github.com/EliteMasterEric/json2object" }, { "name": "lime", @@ -139,7 +139,7 @@ "name": "openfl", "type": "git", "dir": null, - "ref": "d33d489a137ff8fdece4994cf1302f0b6334ed08", + "ref": "de9395d2f367a80f93f082e1b639b9cde2258bf1", "url": "https://github.com/EliteMasterEric/openfl" }, { @@ -160,4 +160,4 @@ "version": "0.11.0" } ] -} \ No newline at end of file +} diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx index ee2dfe5fd..c8c9c79b7 100644 --- a/source/funkin/Paths.hx +++ b/source/funkin/Paths.hx @@ -6,9 +6,6 @@ import openfl.utils.Assets as OpenFlAssets; class Paths { - public static var SOUND_EXT = #if web "mp3" #else "ogg" #end; - public static var VIDEO_EXT = "mp4"; - static var currentLevel:String; static public function setCurrentLevel(name:String) @@ -84,7 +81,7 @@ class Paths static public function sound(key:String, ?library:String) { - return getPath('sounds/$key.$SOUND_EXT', SOUND, library); + return getPath('sounds/$key.${Constants.EXT_SOUND}', SOUND, library); } inline static public function soundRandom(key:String, min:Int, max:Int, ?library:String) @@ -94,24 +91,24 @@ class Paths inline static public function music(key:String, ?library:String) { - return getPath('music/$key.$SOUND_EXT', MUSIC, library); + return getPath('music/$key.${Constants.EXT_SOUND}', MUSIC, library); } inline static public function videos(key:String, ?library:String) { - return getPath('videos/$key.$VIDEO_EXT', BINARY, library); + return getPath('videos/$key.${Constants.EXT_VIDEO}', BINARY, library); } inline static public function voices(song:String, ?suffix:String = '') { if (suffix == null) suffix = ""; // no suffix, for a sorta backwards compatibility with older-ish voice files - return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT'; + return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.${Constants.EXT_SOUND}'; } inline static public function inst(song:String, ?suffix:String = '') { - return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT'; + return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.${Constants.EXT_SOUND}'; } inline static public function image(key:String, ?library:String) diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx index 24d0de476..70615069b 100644 --- a/source/funkin/data/BaseRegistry.hx +++ b/source/funkin/data/BaseRegistry.hx @@ -4,9 +4,6 @@ import openfl.Assets; import funkin.util.assets.DataAssets; import funkin.util.VersionUtil; import haxe.Constraints.Constructible; -import json2object.Position; -import json2object.Position.Line; -import json2object.Error; /** * The entry's constructor function must take a single argument, the entry's ID. @@ -179,6 +176,15 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo */ public abstract function parseEntryData(id:String):Null<J>; + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + */ + public abstract function parseEntryDataRaw(contents:String, ?fileName:String):Null<J>; + /** * Read, parse, and validate the JSON data and produce the corresponding data object, * accounting for old versions of the data. @@ -226,79 +232,12 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo */ abstract function createScriptedEntry(clsName:String):Null<T>; - function printErrors(errors:Array<Error>, id:String = ''):Void + function printErrors(errors:Array<json2object.Error>, id:String = ''):Void { trace('[${registryId}] Failed to parse entry data: ${id}'); for (error in errors) - printError(error); - } - - function printError(error:Error):Void - { - switch (error) - { - case IncorrectType(vari, expected, pos): - trace(' Expected field "$vari" to be of type "$expected".'); - printPos(pos); - case IncorrectEnumValue(value, expected, pos): - trace(' Invalid enum value (expected "$expected", got "$value")'); - printPos(pos); - case InvalidEnumConstructor(value, expected, pos): - trace(' Invalid enum constructor (epxected "$expected", got "$value")'); - printPos(pos); - case UninitializedVariable(vari, pos): - trace(' Uninitialized variable "$vari"'); - printPos(pos); - case UnknownVariable(vari, pos): - trace(' Unknown variable "$vari"'); - printPos(pos); - case ParserError(message, pos): - trace(' Parsing error: ${message}'); - printPos(pos); - case CustomFunctionException(e, pos): - if (Std.isOfType(e, String)) - { - trace(' ${e}'); - } - else - { - printUnknownError(e); - } - printPos(pos); - default: - printUnknownError(error); - } - } - - function printUnknownError(e:Dynamic):Void - { - switch (Type.typeof(e)) - { - case TClass(c): - trace(' [${Type.getClassName(c)}] ${e.toString()}'); - case TEnum(c): - trace(' [${Type.getEnumName(c)}] ${e.toString()}'); - default: - trace(' [${Type.typeof(e)}] ${e.toString()}'); - } - } - - /** - * TODO: Figure out the nicest way to print this. - * Maybe look up how other JSON parsers format their errors? - * @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx - */ - function printPos(pos:Position):Void - { - if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number) - { - trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}'); - } - else - { - trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}'); - } + DataError.printError(error); } } diff --git a/source/funkin/data/DataError.hx b/source/funkin/data/DataError.hx new file mode 100644 index 000000000..87c99fff5 --- /dev/null +++ b/source/funkin/data/DataError.hx @@ -0,0 +1,75 @@ +package funkin.data; + +import json2object.Position; +import json2object.Position.Line; +import json2object.Error; + +class DataError +{ + public static function printError(error:Error):Void + { + switch (error) + { + case IncorrectType(vari, expected, pos): + trace(' Expected field "$vari" to be of type "$expected".'); + printPos(pos); + case IncorrectEnumValue(value, expected, pos): + trace(' Invalid enum value (expected "$expected", got "$value")'); + printPos(pos); + case InvalidEnumConstructor(value, expected, pos): + trace(' Invalid enum constructor (epxected "$expected", got "$value")'); + printPos(pos); + case UninitializedVariable(vari, pos): + trace(' Uninitialized variable "$vari"'); + printPos(pos); + case UnknownVariable(vari, pos): + trace(' Unknown variable "$vari"'); + printPos(pos); + case ParserError(message, pos): + trace(' Parsing error: ${message}'); + printPos(pos); + case CustomFunctionException(e, pos): + if (Std.isOfType(e, String)) + { + trace(' ${e}'); + } + else + { + printUnknownError(e); + } + printPos(pos); + default: + printUnknownError(error); + } + } + + public static function printUnknownError(e:Dynamic):Void + { + switch (Type.typeof(e)) + { + case TClass(c): + trace(' [${Type.getClassName(c)}] ${e.toString()}'); + case TEnum(c): + trace(' [${Type.getEnumName(c)}] ${e.toString()}'); + default: + trace(' [${Type.typeof(e)}] ${e.toString()}'); + } + } + + /** + * TODO: Figure out the nicest way to print this. + * Maybe look up how other JSON parsers format their errors? + * @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx + */ + static function printPos(pos:Position):Void + { + if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number) + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}'); + } + else + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}'); + } + } +} diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx index f6b5dd659..4a422b368 100644 --- a/source/funkin/data/DataParse.hx +++ b/source/funkin/data/DataParse.hx @@ -1,7 +1,13 @@ package funkin.data; +import funkin.data.song.importer.FNFLegacyData.LegacyNote; import hxjsonast.Json; +import hxjsonast.Tools; import hxjsonast.Json.JObjectField; +import haxe.ds.Either; +import funkin.data.song.importer.FNFLegacyData.LegacyNoteSection; +import funkin.data.song.importer.FNFLegacyData.LegacyNoteData; +import funkin.data.song.importer.FNFLegacyData.LegacyScrollSpeeds; /** * `json2object` has an annotation `@:jcustomparse` which allows for mutation of parsed values. @@ -39,36 +45,40 @@ class DataParse */ public static function dynamicValue(json:Json, name:String):Dynamic { - return jsonToDynamic(json); + return Tools.getValue(json); } /** - * Parser which outputs a Dynamic value, which must be an object with properties. - * @param json - * @param name - * @return Dynamic + * Parser which outputs a `Either<Array<LegacyNoteSection>, LegacyNoteData>`. + * Used by the FNF legacy JSON importer. */ - public static function dynamicObject(json:Json, name:String):Dynamic + public static function eitherLegacyNoteData(json:Json, name:String):Either<Array<LegacyNoteSection>, LegacyNoteData> { switch (json.value) { + case JArray(values): + return Either.Left(legacyNoteSectionArray(json, name)); case JObject(fields): - return jsonFieldsToDynamicObject(fields); + return Either.Right(cast Tools.getValue(json)); default: - throw 'Expected property $name to be an object, but it was ${json.value}.'; + throw 'Expected property $name to be note data, but it was ${json.value}.'; } } - static function jsonToDynamic(json:Json):Null<Dynamic> + /** + * Parser which outputs a `Either<Float, LegacyScrollSpeeds>`. + * Used by the FNF legacy JSON importer. + */ + public static function eitherLegacyScrollSpeeds(json:Json, name:String):Either<Float, LegacyScrollSpeeds> { - return switch (json.value) + switch (json.value) { - case JString(s): s; - case JNumber(n): Std.parseInt(n); - case JBool(b): b; - case JNull: null; - case JObject(fields): jsonFieldsToDynamicObject(fields); - case JArray(values): jsonArrayToDynamicArray(values); + case JNumber(f): + return Either.Left(Std.parseFloat(f)); + case JObject(fields): + return Either.Right(cast Tools.getValue(json)); + default: + throw 'Expected property $name to be scroll speeds, but it was ${json.value}.'; } } @@ -82,7 +92,7 @@ class DataParse var result:Dynamic = {}; for (field in fields) { - Reflect.setField(result, field.name, jsonToDynamic(field.value)); + Reflect.setField(result, field.name, Tools.getValue(field.value)); } return result; } @@ -94,6 +104,67 @@ class DataParse */ static function jsonArrayToDynamicArray(jsons:Array<Json>):Array<Null<Dynamic>> { - return [for (json in jsons) jsonToDynamic(json)]; + return [for (json in jsons) Tools.getValue(json)]; + } + + static function legacyNoteSectionArray(json:Json, name:String):Array<LegacyNoteSection> + { + switch (json.value) + { + case JArray(values): + return [for (value in values) legacyNoteSection(value, name)]; + default: + throw 'Expected property to be an array, but it was ${json.value}.'; + } + } + + static function legacyNoteSection(json:Json, name:String):LegacyNoteSection + { + switch (json.value) + { + case JObject(fields): + return cast Tools.getValue(json); + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + public static function legacyNoteData(json:Json, name:String):LegacyNoteData + { + switch (json.value) + { + case JObject(fields): + return cast Tools.getValue(json); + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + public static function legacyNotes(json:Json, name:String):Array<LegacyNote> + { + switch (json.value) + { + case JArray(values): + return [for (value in values) legacyNote(value, name)]; + default: + throw 'Expected property $name to be an array of notes, but it was ${json.value}.'; + } + } + + public static function legacyNote(json:Json, name:String):LegacyNote + { + switch (json.value) + { + case JArray(values): + // var time:Null<Float> = values[0] == null ? null : Tools.getValue(values[0]); + // var data:Null<Int> = values[1] == null ? null : Tools.getValue(values[1]); + // var length:Null<Float> = values[2] == null ? null : Tools.getValue(values[2]); + // var alt:Null<Bool> = values[3] == null ? null : Tools.getValue(values[3]); + + // return new LegacyNote(time, data, length, alt); + return null; + default: + throw 'Expected property $name to be a note, but it was ${json.value}.'; + } } } diff --git a/source/funkin/data/DataWrite.hx b/source/funkin/data/DataWrite.hx index 2ff7672da..41993107f 100644 --- a/source/funkin/data/DataWrite.hx +++ b/source/funkin/data/DataWrite.hx @@ -1,8 +1,17 @@ package funkin.data; +import funkin.util.SerializerUtil; + /** * `json2object` has an annotation `@:jcustomwrite` which allows for custom serialization of values to be written to JSON. * * Functions must be of the signature `(T) -> String`, where `T` is the type of the property. */ -class DataWrite {} +class DataWrite +{ + public static function dynamicValue(value:Dynamic):String + { + // Is this cheating? Yes. Do I care? No. + return SerializerUtil.toJSON(value); + } +} diff --git a/source/funkin/data/animation/AnimationData.hx b/source/funkin/data/animation/AnimationData.hx index 2116109db..9765f784c 100644 --- a/source/funkin/data/animation/AnimationData.hx +++ b/source/funkin/data/animation/AnimationData.hx @@ -67,7 +67,6 @@ typedef UnnamedAnimationData = * ONLY for use by MultiSparrow characters. * @default The assetPath of the parent sprite */ - @:default(null) @:optional var assetPath:Null<String>; @@ -85,7 +84,7 @@ typedef UnnamedAnimationData = */ @:default(false) @:optional - var looped:Null<Bool>; + var looped:Bool; /** * Whether the animation's sprites should be flipped horizontally. diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx index d135e1241..75b0b11f6 100644 --- a/source/funkin/data/level/LevelRegistry.hx +++ b/source/funkin/data/level/LevelRegistry.hx @@ -47,6 +47,26 @@ class LevelRegistry extends BaseRegistry<Level, LevelData> return parser.value; } + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String):Null<LevelData> + { + var parser = new json2object.JsonParser<LevelData>(); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + function createScriptedEntry(clsName:String):Level { return ScriptedLevel.init(clsName, "unknown"); diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx index bb594bca4..da45da5f2 100644 --- a/source/funkin/data/notestyle/NoteStyleRegistry.hx +++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx @@ -54,6 +54,26 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData> return parser.value; } + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String):Null<NoteStyleData> + { + var parser = new json2object.JsonParser<NoteStyleData>(); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + function createScriptedEntry(clsName:String):NoteStyle { return ScriptedNoteStyle.init(clsName, "unknown"); diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx index 59f8fcaf1..d557bd39c 100644 --- a/source/funkin/data/song/SongData.hx +++ b/source/funkin/data/song/SongData.hx @@ -1,8 +1,6 @@ package funkin.data.song; import flixel.util.typeLimit.OneOfTwo; -import funkin.play.song.SongMigrator; -import funkin.play.song.SongValidator; import funkin.data.song.SongRegistry; import thx.semver.Version; @@ -47,32 +45,33 @@ class SongMetadata * Defaults to `default` or `''`. Populated later. */ @:jignored - public var variation:String = 'default'; + public var variation:String; - public function new(songName:String, artist:String, variation:String = 'default') + public function new(songName:String, artist:String, ?variation:String) { - this.version = SongMigrator.CHART_VERSION; + this.version = SongRegistry.SONG_METADATA_VERSION; this.songName = songName; this.artist = artist; this.timeFormat = 'ms'; this.divisions = null; this.timeChanges = [new SongTimeChange(0, 100)]; this.looped = false; - this.playData = - { - songVariations: [], - difficulties: ['normal'], - - playableChars: ['bf' => new SongPlayableChar('gf', 'dad')], - - stage: 'mainStage', - noteSkin: 'Normal' - }; + this.playData = new SongPlayData(); + this.playData.songVariations = []; + this.playData.difficulties = []; + this.playData.characters = new SongCharacterData('bf', 'gf', 'dad'); + this.playData.stage = 'mainStage'; + this.playData.noteSkin = 'funkin'; this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; // Variation ID. - this.variation = variation; + this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation; } + /** + * Create a copy of this SongMetadata with the same information. + * @param newVariation Set to a new variation ID to change the new metadata. + * @return The cloned SongMetadata + */ public function clone(?newVariation:String = null):SongMetadata { var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); @@ -87,6 +86,22 @@ class SongMetadata return result; } + /** + * Serialize this SongMetadata into a JSON string. + * @return The JSON string. + */ + public function serialize(pretty:Bool = true):String + { + var writer = new json2object.JsonWriter<SongMetadata>(); + // I believe @:jignored should be iggnored by the writer? + // var output = this.clone(); + // output.variation = null; // Not sure how to make a field optional on the reader and ignored on the writer. + return writer.write(this, pretty ? ' ' : null); + } + + /** + * Produces a string representation suitable for debugging. + */ public function toString():String { return 'SongMetadata(${this.songName} by ${this.artist}, variation ${this.variation})'; @@ -121,7 +136,6 @@ class SongTimeChange */ @:optional @:alias("b") - // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_BEAT_TIME) public var beatTime:Null<Float>; /** @@ -168,6 +182,9 @@ class SongTimeChange this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets; } + /** + * Produces a string representation suitable for debugging. + */ public function toString():String { return 'SongTimeChange(${this.timeStamp}ms,${this.bpm}bpm)'; @@ -199,7 +216,7 @@ class SongMusicData @:optional @:default(false) - public var looped:Bool; + public var looped:Null<Bool>; // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) public var generatedBy:String; @@ -214,11 +231,11 @@ class SongMusicData * Defaults to `default` or `''`. Populated later. */ @:jignored - public var variation:String = 'default'; + public var variation:String = Constants.DEFAULT_VARIATION; public function new(songName:String, artist:String, variation:String = 'default') { - this.version = SongMigrator.CHART_VERSION; + this.version = SongRegistry.SONG_CHART_DATA_VERSION; this.songName = songName; this.artist = artist; this.timeFormat = 'ms'; @@ -227,7 +244,7 @@ class SongMusicData this.looped = false; this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; // Variation ID. - this.variation = variation; + this.variation = variation == null ? Constants.DEFAULT_VARIATION : variation; } public function clone(?newVariation:String = null):SongMusicData @@ -243,53 +260,106 @@ class SongMusicData return result; } + /** + * Produces a string representation suitable for debugging. + */ public function toString():String { return 'SongMusicData(${this.songName} by ${this.artist}, variation ${this.variation})'; } } -typedef SongPlayData = +class SongPlayData { + /** + * The variations this song has. The associated metadata files should exist. + */ public var songVariations:Array<String>; + + /** + * The difficulties contained in this song's chart file. + */ public var difficulties:Array<String>; /** - * Keys are the player characters and the values give info on what opponent/GF/inst to use. + * The characters used by this song. */ - public var playableChars:Map<String, SongPlayableChar>; + public var characters:SongCharacterData; + /** + * The stage used by this song. + */ public var stage:String; + + /** + * The note style used by this song. + * TODO: Rename to `noteStyle`? Renaming values is a breaking change to the metadata format. + */ public var noteSkin:String; + + /** + * The difficulty rating for this song as displayed in Freeplay. + * TODO: Adding this is a non-breaking change to the metadata format. + */ + // public var rating:Int; + + /** + * The album ID for the album to display in Freeplay. + * TODO: Adding this is a non-breaking change to the metadata format. + */ + // public var album:String; + + public function new() {} + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongPlayData(${this.songVariations}, ${this.difficulties})'; + } } -class SongPlayableChar +/** + * Information about the characters used in this variation of the song. + * Create a new variation if you want to change the characters. + */ +class SongCharacterData { - @:alias('g') + @:optional + @:default('') + public var player:String = ''; + @:optional @:default('') public var girlfriend:String = ''; - @:alias('o') @:optional @:default('') public var opponent:String = ''; - @:alias('i') @:optional @:default('') - public var inst:String = ''; + public var instrumental:String = ''; - public function new(girlfriend:String = '', opponent:String = '', inst:String = '') + @:optional + @:default([]) + public var altInstrumentals:Array<String> = []; + + public function new(player:String = '', girlfriend:String = '', opponent:String = '', instrumental:String = '') { + this.player = player; this.girlfriend = girlfriend; this.opponent = opponent; - this.inst = inst; + this.instrumental = instrumental; } + /** + * Produces a string representation suitable for debugging. + */ public function toString():String { - return 'SongPlayableChar(${this.girlfriend}, ${this.opponent}, ${this.inst})'; + return 'SongCharacterData(${this.player}, ${this.girlfriend}, ${this.opponent}, ${this.instrumental}, [${this.altInstrumentals.join(', ')}])'; } } @@ -305,6 +375,9 @@ class SongChartData @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) public var generatedBy:String; + @:jignored + public var variation:String; + public function new(scrollSpeed:Map<String, Float>, events:Array<SongEventData>, notes:Map<String, Array<SongNoteData>>) { this.version = SongRegistry.SONG_CHART_DATA_VERSION; @@ -346,14 +419,21 @@ class SongChartData return value; } - public function getEvents():Array<SongEventData> + /** + * Convert this SongChartData into a JSON string. + */ + public function serialize(pretty:Bool = true):String { - return this.events; + var writer = new json2object.JsonWriter<SongChartData>(); + return writer.write(this, pretty ? ' ' : null); } - public function setEvents(value:Array<SongEventData>):Array<SongEventData> + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String { - return this.events = value; + return 'SongChartData(${this.events.length} events, ${this.notes.size()} difficulties, ${generatedBy})'; } } @@ -387,6 +467,7 @@ class SongEventData @:alias("v") @:optional @:jcustomparse(funkin.data.DataParse.dynamicValue) + @:jcustomwrite(funkin.data.DataWrite.dynamicValue) public var value:Dynamic = null; /** @@ -484,6 +565,9 @@ class SongEventData return this.time <= other.time; } + /** + * Produces a string representation suitable for debugging. + */ public function toString():String { return 'SongEventData(${this.time}ms, ${this.event}: ${this.value})'; @@ -703,6 +787,9 @@ class SongNoteData return this.time <= other.time; } + /** + * Produces a string representation suitable for debugging. + */ public function toString():String { return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}' diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx index d15a2b19a..4b9318df2 100644 --- a/source/funkin/data/song/SongDataUtils.hx +++ b/source/funkin/data/song/SongDataUtils.hx @@ -8,6 +8,9 @@ import funkin.util.SerializerUtil; using Lambda; +/** + * Utility functions for working with song data, including note data, event data, metadata, etc. + */ class SongDataUtils { /** diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx index 9bc1278c8..cf2da14f7 100644 --- a/source/funkin/data/song/SongRegistry.hx +++ b/source/funkin/data/song/SongRegistry.hx @@ -1,6 +1,7 @@ package funkin.data.song; import funkin.data.song.SongData; +import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0; import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongMetadata; import funkin.play.song.ScriptedSong; @@ -8,6 +9,8 @@ import funkin.play.song.Song; import funkin.util.assets.DataAssets; import funkin.util.VersionUtil; +using funkin.data.song.migrator.SongDataMigrator; + class SongRegistry extends BaseRegistry<Song, SongMetadata> { /** @@ -15,14 +18,18 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> * Handle breaking changes by incrementing this value * and adding migration to the `migrateStageData()` function. */ - public static final SONG_METADATA_VERSION:thx.semver.Version = "2.0.0"; + public static final SONG_METADATA_VERSION:thx.semver.Version = "2.1.0"; - public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.1.x"; public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0"; public static final SONG_CHART_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + public static final SONG_MUSIC_DATA_VERSION:thx.semver.Version = "2.0.0"; + + public static final SONG_MUSIC_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + public static var DEFAULT_GENERATEDBY(get, null):String; static function get_DEFAULT_GENERATEDBY():String @@ -30,6 +37,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> return '${Constants.TITLE} - ${Constants.VERSION}'; } + /** + * TODO: What if there was a Singleton macro which created static functions + * that redirected to the instance? + */ public static final instance:SongRegistry = new SongRegistry(); public function new() @@ -101,12 +112,91 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> return parseEntryMetadata(id); } - public function parseEntryMetadata(id:String, variation:String = ""):Null<SongMetadata> + /** + * Parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String = 'raw'):Null<SongMetadata> { - // JsonParser does not take type parameters, - // otherwise this function would be in BaseRegistry. + return parseEntryMetadataRaw(contents); + } + + public function parseEntryMetadata(id:String, ?variation:String):Null<SongMetadata> + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser<SongMetadata>(); + switch (loadEntryMetadataFile(id, variation)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return cleanMetadata(parser.value, variation); + } + + public function parseEntryMetadataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null<SongMetadata> + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser<SongMetadata>(); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return cleanMetadata(parser.value, variation); + } + + public function parseEntryMetadataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongMetadata> + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + // If a version rule is not specified, do not check against it. + if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE)) + { + return parseEntryMetadata(id, variation); + } + else if (VersionUtil.validateVersion(version, "2.0.x")) + { + return parseEntryMetadata_v2_0_0(id, variation); + } + else + { + throw '[${registryId}] Metadata entry ${id}:${variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.'; + } + } + + public function parseEntryMetadataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongMetadata> + { + // If a version rule is not specified, do not check against it. + if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE)) + { + return parseEntryMetadataRaw(contents, fileName); + } + else if (VersionUtil.validateVersion(version, "2.0.x")) + { + return parseEntryMetadataRaw_v2_0_0(contents, fileName); + } + else + { + throw '[${registryId}] Metadata entry "${fileName}" does not support migration to version ${SONG_METADATA_VERSION_RULE}.'; + } + } + + function parseEntryMetadata_v2_0_0(id:String, variation:String = ""):Null<SongMetadata> + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser<SongMetadata_v2_0_0>(); switch (loadEntryMetadataFile(id)) { case {fileName: fileName, contents: contents}: @@ -114,6 +204,39 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> default: return null; } + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value.migrate(); + } + + function parseEntryMetadataRaw_v2_0_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata> + { + var parser = new json2object.JsonParser<SongMetadata_v2_0_0>(); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value.migrate(); + } + + public function parseMusicData(id:String, ?variation:String):Null<SongMusicData> + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser<SongMusicData>(); + switch (loadMusicDataFile(id, variation)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { @@ -123,48 +246,54 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> return parser.value; } - public function parseEntryMetadataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null<SongMetadata> + public function parseMusicDataRaw(contents:String, ?fileName:String = 'raw'):Null<SongMusicData> { - // If a version rule is not specified, do not check against it. - if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE)) + var parser = new json2object.JsonParser<SongMusicData>(); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) { - return parseEntryMetadata(id); + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + + public function parseMusicDataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongMusicData> + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + // If a version rule is not specified, do not check against it. + if (SONG_MUSIC_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_MUSIC_DATA_VERSION_RULE)) + { + return parseMusicData(id, variation); } else { - throw '[${registryId}] Metadata entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.'; + throw '[${registryId}] Chart entry ${id}:${variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; } } - public function parseMusicData(id:String, variation:String = ""):Null<SongMusicData> + public function parseMusicDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongMusicData> { - // JsonParser does not take type parameters, - // otherwise this function would be in BaseRegistry. - - var parser = new json2object.JsonParser<SongMusicData>(); - switch (loadMusicDataFile(id)) + // If a version rule is not specified, do not check against it. + if (SONG_MUSIC_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_MUSIC_DATA_VERSION_RULE)) { - case {fileName: fileName, contents: contents}: - parser.fromJson(contents, fileName); - default: - return null; + return parseMusicDataRaw(contents, fileName); } - - if (parser.errors.length > 0) + else { - printErrors(parser.errors, id); - return null; + throw '[${registryId}] Chart entry "$fileName" does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; } - return parser.value; } - public function parseEntryChartData(id:String, variation:String = ''):Null<SongChartData> + public function parseEntryChartData(id:String, ?variation:String):Null<SongChartData> { - // JsonParser does not take type parameters, - // otherwise this function would be in BaseRegistry. + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var parser = new json2object.JsonParser<SongChartData>(); - switch (loadEntryChartFile(id)) + switch (loadEntryChartFile(id, variation)) { case {fileName: fileName, contents: contents}: parser.fromJson(contents, fileName); @@ -177,11 +306,28 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> printErrors(parser.errors, id); return null; } - return parser.value; + return cleanChartData(parser.value, variation); } - public function parseEntryChartDataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null<SongChartData> + public function parseEntryChartDataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null<SongChartData> { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser<SongChartData>(); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return cleanChartData(parser.value, variation); + } + + public function parseEntryChartDataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongChartData> + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + // If a version rule is not specified, do not check against it. if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE)) { @@ -189,7 +335,20 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> } else { - throw '[${registryId}] Chart entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + throw '[${registryId}] Chart entry ${id}:${variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + } + } + + public function parseEntryChartDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongChartData> + { + // If a version rule is not specified, do not check against it. + if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE)) + { + return parseEntryChartDataRaw(contents, fileName); + } + else + { + throw '[${registryId}] Chart entry "${fileName}" does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; } } @@ -203,9 +362,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> return ScriptedSong.listScriptClasses(); } - function loadEntryMetadataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile> + function loadEntryMetadataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile> { - var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-metadata'); + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}'); if (!openfl.Assets.exists(entryFilePath)) return null; var rawJson:Null<String> = openfl.Assets.getText(entryFilePath); if (rawJson == null) return null; @@ -213,9 +373,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> return {fileName: entryFilePath, contents: rawJson}; } - function loadMusicDataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile> + function loadMusicDataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile> { - var entryFilePath:String = Paths.file('music/$id/$id${variation == '' ? '' : '-$variation'}-metadata.json'); + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json'); if (!openfl.Assets.exists(entryFilePath)) return null; var rawJson:String = openfl.Assets.getText(entryFilePath); if (rawJson == null) return null; @@ -223,9 +384,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> return {fileName: entryFilePath, contents: rawJson}; } - function loadEntryChartFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile> + function loadEntryChartFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile> { - var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-chart'); + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}'); if (!openfl.Assets.exists(entryFilePath)) return null; var rawJson:String = openfl.Assets.getText(entryFilePath); if (rawJson == null) return null; @@ -233,20 +395,36 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> return {fileName: entryFilePath, contents: rawJson}; } - public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null<thx.semver.Version> + public function fetchEntryMetadataVersion(id:String, ?variation:String):Null<thx.semver.Version> { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryStr:Null<String> = loadEntryMetadataFile(id, variation)?.contents; var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } - public function fetchEntryChartVersion(id:String, variation:String = ''):Null<thx.semver.Version> + public function fetchEntryChartVersion(id:String, ?variation:String):Null<thx.semver.Version> { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryStr:Null<String> = loadEntryChartFile(id, variation)?.contents; var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } + function cleanMetadata(metadata:SongMetadata, variation:String):SongMetadata + { + metadata.variation = variation; + + return metadata; + } + + function cleanChartData(chartData:SongChartData, variation:String):SongChartData + { + chartData.variation = variation; + + return chartData; + } + /** * A list of all the story weeks from the base game, in order. * TODO: Should this be hardcoded? diff --git a/source/funkin/data/song/importer/FNFLegacyData.hx b/source/funkin/data/song/importer/FNFLegacyData.hx new file mode 100644 index 000000000..5b75368c9 --- /dev/null +++ b/source/funkin/data/song/importer/FNFLegacyData.hx @@ -0,0 +1,124 @@ +package funkin.data.song.importer; + +import haxe.ds.Either; + +/** + * A data structure representing a song in the old chart format. + * This only works for charts compatible with Week 7, so you'll need a custom program + * to handle importing charts from mods or other engines. + */ +class FNFLegacyData +{ + public var song:LegacySongData; +} + +class LegacySongData +{ + public var player1:String; // Boyfriend + public var player2:String; // Opponent + + @:jcustomparse(funkin.data.DataParse.eitherLegacyScrollSpeeds) + public var speed:Either<Float, LegacyScrollSpeeds>; + public var stageDefault:String; + public var bpm:Float; + + @:jcustomparse(funkin.data.DataParse.eitherLegacyNoteData) + public var notes:Either<Array<LegacyNoteSection>, LegacyNoteData>; + public var song:String; // Song name + + public function new() {} + + public function toString():String + { + var notesStr:String = switch (notes) + { + case Left(sections): 'single difficulty w/ ${sections.length} sections'; + case Right(data): + var difficultyCount:Int = 0; + if (data.easy != null) difficultyCount++; + if (data.normal != null) difficultyCount++; + if (data.hard != null) difficultyCount++; + '${difficultyCount} difficulties'; + }; + return 'LegacySongData($player1, $player2, $notesStr)'; + } +} + +typedef LegacyScrollSpeeds = +{ + public var ?easy:Float; + public var ?normal:Float; + public var ?hard:Float; +}; + +typedef LegacyNoteData = +{ + /** + * The easy difficulty. + */ + public var ?easy:Array<LegacyNoteSection>; + + /** + * The normal difficulty. + */ + public var ?normal:Array<LegacyNoteSection>; + + /** + * The hard difficulty. + */ + public var ?hard:Array<LegacyNoteSection>; +}; + +typedef LegacyNoteSection = +{ + /** + * Whether the section is a must-hit section. + * If true, 0-3 are boyfriends notes, 4-7 are opponents notes. + * If false, 0-3 are opponents notes, 4-7 are boyfriends notes. + */ + public var mustHitSection:Bool; + + /** + * Array of note data: + * - Direction + * - Time (ms) + * - Sustain Duration (ms) + * - Note kind (true = "alt", or string) + */ + public var sectionNotes:Array<LegacyNote>; + + public var ?typeOfSection:Int; + + public var ?lengthInSteps:Int; + + // BPM changes + public var ?changeBPM:Bool; + public var ?bpm:Float; +} + +/** + * Notes in the old format are stored as an Array<Dynamic> + * We use a custom parser to manage this. + */ +@:jcustomparse(funkin.data.DataParse.legacyNote) +class LegacyNote +{ + public var time:Float; + public var data:Int; + public var length:Float; + public var alt:Bool; + + public function new(time:Float, data:Int, ?length:Float, ?alt:Bool) + { + this.time = time; + this.data = data; + + this.length = length ?? 0.0; + this.alt = alt ?? false; + } + + public inline function getKind():String + { + return this.alt ? 'alt' : 'normal'; + } +} diff --git a/source/funkin/data/song/importer/FNFLegacyImporter.hx b/source/funkin/data/song/importer/FNFLegacyImporter.hx new file mode 100644 index 000000000..ee68513dc --- /dev/null +++ b/source/funkin/data/song/importer/FNFLegacyImporter.hx @@ -0,0 +1,202 @@ +package funkin.data.song.importer; // import is a reserved word dumbass + +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongTimeChange; +import funkin.data.song.importer.FNFLegacyData; +import funkin.data.song.importer.FNFLegacyData.LegacyNoteSection; + +class FNFLegacyImporter +{ + public static function parseLegacyDataRaw(input:String, fileName:String = 'raw'):FNFLegacyData + { + var parser = new json2object.JsonParser<FNFLegacyData>(); + parser.fromJson(input, fileName); + + if (parser.errors.length > 0) + { + trace('[FNFLegacyImporter] Error parsing JSON data from ' + fileName + ':'); + for (error in parser.errors) + DataError.printError(error); + return null; + } + return parser.value; + } + + /** + * @param data The raw parsed JSON data to migrate, as a Dynamic. + * @param difficulty + * @return SongMetadata + */ + public static function migrateMetadata(songData:FNFLegacyData, difficulty:String = 'normal'):SongMetadata + { + trace('Migrating song metadata from FNF Legacy.'); + + var songMetadata:SongMetadata = new SongMetadata('Import', 'Kawai Sprite', 'default'); + + var hadError:Bool = false; + + // Set generatedBy string for debugging. + songMetadata.generatedBy = 'Chart Editor Import (FNF Legacy)'; + + songMetadata.playData.stage = songData?.song?.stageDefault ?? 'mainStage'; + songMetadata.songName = songData?.song?.song ?? 'Import'; + songMetadata.playData.difficulties = []; + + if (songData?.song?.notes != null) + { + switch (songData.song.notes) + { + case Left(notes): + // One difficulty of notes. + songMetadata.playData.difficulties.push(difficulty); + case Right(difficulties): + if (difficulties.easy != null) songMetadata.playData.difficulties.push('easy'); + if (difficulties.normal != null) songMetadata.playData.difficulties.push('normal'); + if (difficulties.hard != null) songMetadata.playData.difficulties.push('hard'); + } + } + + songMetadata.playData.songVariations = []; + + songMetadata.timeChanges = rebuildTimeChanges(songData); + + songMetadata.playData.characters = new SongCharacterData(songData?.song?.player1 ?? 'bf', 'gf', songData?.song?.player2 ?? 'dad', 'mom'); + + return songMetadata; + } + + public static function migrateChartData(songData:FNFLegacyData, difficulty:String = 'normal'):SongChartData + { + trace('Migrating song chart data from FNF Legacy.'); + + var songChartData:SongChartData = new SongChartData([difficulty => 1.0], [], [difficulty => []]); + + if (songData?.song?.notes != null) + { + switch (songData.song.notes) + { + case Left(notes): + // One difficulty of notes. + songChartData.notes.set(difficulty, migrateNoteSections(notes)); + case Right(difficulties): + var baseDifficulty = null; + if (difficulties.easy != null) songChartData.notes.set('easy', migrateNoteSections(difficulties.easy)); + if (difficulties.normal != null) songChartData.notes.set('normal', migrateNoteSections(difficulties.normal)); + if (difficulties.hard != null) songChartData.notes.set('hard', migrateNoteSections(difficulties.hard)); + } + } + + // Import event data. + songChartData.events = rebuildEventData(songData); + + switch (songData.song.speed) + { + case Left(speed): + // All difficulties will use the one scroll speed. + songChartData.scrollSpeed.set('default', speed); + case Right(speeds): + if (speeds.easy != null) songChartData.scrollSpeed.set('easy', speeds.easy); + if (speeds.normal != null) songChartData.scrollSpeed.set('normal', speeds.normal); + if (speeds.hard != null) songChartData.scrollSpeed.set('hard', speeds.hard); + } + + return songChartData; + } + + /** + * FNF Legacy doesn't have song events, but without them the song won't look right, + * so we insert camera events when the character changes. + */ + static function rebuildEventData(songData:FNFLegacyData):Array<SongEventData> + { + var result:Array<SongEventData> = []; + + var noteSections = []; + switch (songData.song.notes) + { + case Left(notes): + // All difficulties will use the one scroll speed. + noteSections = notes; + case Right(difficulties): + if (difficulties.normal != null) noteSections = difficulties.normal; + if (difficulties.hard != null) noteSections = difficulties.normal; + if (difficulties.easy != null) noteSections = difficulties.normal; + } + + if (noteSections == null || noteSections.length == 0) return result; + + // Add camera events. + var lastSectionWasMustHit:Null<Bool> = null; + for (section in noteSections) + { + // Skip empty sections. + if (section.sectionNotes.length == 0) continue; + + if (section.mustHitSection != lastSectionWasMustHit) + { + lastSectionWasMustHit = section.mustHitSection; + + var firstNote:LegacyNote = section.sectionNotes[0]; + + result.push(new SongEventData(firstNote.time, 'FocusCamera', {char: section.mustHitSection ? 0 : 1})); + } + } + + return result; + } + + /** + * Port over time changes from FNF Legacy. + * If a section contains a BPM change, it will be applied at the timestamp of the first note in that section. + */ + static function rebuildTimeChanges(songData:FNFLegacyData):Array<SongTimeChange> + { + var result:Array<SongTimeChange> = []; + + result.push(new SongTimeChange(0, songData?.song?.bpm ?? Constants.DEFAULT_BPM)); + + var noteSections = []; + switch (songData.song.notes) + { + case Left(notes): + // All difficulties will use the one scroll speed. + noteSections = notes; + case Right(difficulties): + if (difficulties.normal != null) noteSections = difficulties.normal; + if (difficulties.hard != null) noteSections = difficulties.normal; + if (difficulties.easy != null) noteSections = difficulties.normal; + } + + if (noteSections == null || noteSections.length == 0) return result; + + for (noteSection in noteSections) + { + if (noteSection.changeBPM ?? false) + { + var firstNote:LegacyNote = noteSection.sectionNotes[0]; + if (firstNote != null) result.push(new SongTimeChange(firstNote.time, noteSection.bpm)); + } + } + + return result; + } + + static function migrateNoteSections(input:Array<LegacyNoteSection>):Array<SongNoteData> + { + var result:Array<SongNoteData> = []; + + for (section in input) + { + for (note in section.sectionNotes) + { + result.push(new SongNoteData(note.time, note.data, note.length, note.getKind())); + } + } + + return result; + } +} diff --git a/source/funkin/data/song/migrator/SongDataMigrator.hx b/source/funkin/data/song/migrator/SongDataMigrator.hx new file mode 100644 index 000000000..b5e08c832 --- /dev/null +++ b/source/funkin/data/song/migrator/SongDataMigrator.hx @@ -0,0 +1,66 @@ +package funkin.data.song.migrator; + +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongPlayData; +import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0; +import funkin.data.song.migrator.SongData_v2_0_0.SongPlayData_v2_0_0; +import funkin.data.song.migrator.SongData_v2_0_0.SongPlayableChar_v2_0_0; + +/** + * This class contains functions to migrate older data formats to the current one. + * + * Utilizes static extensions with overloaded inline functions to make migration as easy as `.migrate()`. + * @see https://try.haxe.org/#e1c1cf22 + */ +class SongDataMigrator +{ + public static overload extern inline function migrate(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata + { + return migrate_SongMetadata_v2_0_0(input); + } + + public static function migrate_SongMetadata_v2_0_0(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata + { + var result:SongMetadata = new SongMetadata(input.songName, input.artist, input.variation); + result.version = input.version; + result.timeFormat = input.timeFormat; + result.divisions = input.divisions; + result.timeChanges = input.timeChanges; + result.looped = input.looped; + result.playData = migrate_SongPlayData_v2_0_0(input.playData); + result.generatedBy = input.generatedBy; + + return result; + } + + public static overload extern inline function migrate(input:SongData_v2_0_0.SongPlayData_v2_0_0):SongPlayData + { + return migrate_SongPlayData_v2_0_0(input); + } + + public static function migrate_SongPlayData_v2_0_0(input:SongData_v2_0_0.SongPlayData_v2_0_0):SongPlayData + { + var result:SongPlayData = new SongPlayData(); + result.songVariations = input.songVariations; + result.difficulties = input.difficulties; + result.stage = input.stage; + result.noteSkin = input.noteSkin; + + // Fetch the first playable character and migrate it. + var firstCharKey:Null<String> = input.playableChars.size() == 0 ? null : input.playableChars.keys().array()[0]; + var firstCharData:Null<SongPlayableChar_v2_0_0> = input.playableChars.get(firstCharKey); + + if (firstCharData == null) + { + // Fill in a default playable character. + result.characters = new SongCharacterData('bf', 'gf', 'dad'); + } + else + { + result.characters = new SongCharacterData(firstCharKey, firstCharData.girlfriend, firstCharData.opponent, firstCharData.inst); + } + + return result; + } +} diff --git a/source/funkin/data/song/migrator/SongData_v2_0_0.hx b/source/funkin/data/song/migrator/SongData_v2_0_0.hx new file mode 100644 index 000000000..935e7349c --- /dev/null +++ b/source/funkin/data/song/migrator/SongData_v2_0_0.hx @@ -0,0 +1,122 @@ +package funkin.data.song.migrator; + +import thx.semver.Version; +import funkin.data.song.SongData; + +class SongMetadata_v2_0_0 +{ + // ========== + // MODIFIED VALUES + // =========== + + /** + * In metadata `v2.1.0`, `SongPlayData` was refactored. + */ + public var playData:SongPlayData_v2_0_0; + + /** + * In metadata `v2.1.0`, `variation` was set to `ignore` when writing. + */ + @:optional + @:default('default') + public var variation:String; + + // ========== + // UNMODIFIED VALUES + // ========== + public var version:Version; + + @:default("Unknown") + public var songName:String; + + @:default("Unknown") + public var artist:String; + + @:optional + @:default(96) + public var divisions:Null<Int>; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + public var generatedBy:String; + + public var timeFormat:SongData.SongTimeFormat; + + public var timeChanges:Array<SongData.SongTimeChange>; + + public function new() {} + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongMetadata[LEGACY:v2.0.0](${this.songName} by ${this.artist}, variation ${this.variation})'; + } +} + +class SongPlayData_v2_0_0 +{ + // ========== + // MODIFIED VALUES + // =========== + + /** + * In metadata version `v2.1.0`, this was refactored to a single `SongCharacterData` object. + */ + public var playableChars:Map<String, SongPlayableChar_v2_0_0>; + + // ========== + // UNMODIFIED VALUES + // ========== + public var songVariations:Array<String>; + public var difficulties:Array<String>; + + public var stage:String; + public var noteSkin:String; + + public function new() {} + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongPlayData[LEGACY:v2.0.0](${this.songVariations}, ${this.difficulties})'; + } +} + +class SongPlayableChar_v2_0_0 +{ + @:alias('g') + @:optional + @:default('') + public var girlfriend:String = ''; + + @:alias('o') + @:optional + @:default('') + public var opponent:String = ''; + + @:alias('i') + @:optional + @:default('') + public var inst:String = ''; + + public function new(girlfriend:String = '', opponent:String = '', inst:String = '') + { + this.girlfriend = girlfriend; + this.opponent = opponent; + this.inst = inst; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongPlayableChar[LEGACY:v2.0.0](${this.girlfriend}, ${this.opponent}, ${this.inst})'; + } +} diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 46938215b..ce72fa56c 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -46,7 +46,7 @@ import funkin.play.song.Song; import funkin.data.song.SongRegistry; import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongNoteData; -import funkin.data.song.SongData.SongPlayableChar; +import funkin.data.song.SongData.SongCharacterData; import funkin.play.stage.Stage; import funkin.play.stage.StageData.StageDataParser; import funkin.ui.PopUpStuff; @@ -574,8 +574,8 @@ class PlayState extends MusicBeatSubState // Prepare the current song's instrumental and vocals to be played. if (!overrideMusic && currentChart != null) { - currentChart.cacheInst(currentPlayerId); - currentChart.cacheVocals(currentPlayerId); + currentChart.cacheInst(); + currentChart.cacheVocals(); } // Prepare the Conductor. @@ -733,7 +733,7 @@ class PlayState extends MusicBeatSubState // DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM! // :nerd: um ackshually it's not 13 it's 11.97278911564 - if (Paths.SOUND_EXT == 'mp3') Conductor.offset = Constants.MP3_DELAY_MS; + if (Constants.EXT_SOUND == 'mp3') Conductor.offset = Constants.MP3_DELAY_MS; Conductor.update(); @@ -1344,34 +1344,20 @@ class PlayState extends MusicBeatSubState trace('Song difficulty could not be loaded.'); } - // Switch the character we are playing as by manipulating currentPlayerId. - // TODO: How to choose which one to use for story mode? - var playableChars:Array<String> = currentChart.getPlayableChars(); - - if (playableChars.length == 0) - { - trace('WARNING: No playable characters found for this song.'); - } - else if (playableChars.indexOf(currentPlayerId) == -1) - { - currentPlayerId = playableChars[0]; - } - - // - var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayerId); + var currentCharacterData:SongCharacterData = currentChart.characters; // Switch the character we are playing as by manipulating currentPlayerId. // // GIRLFRIEND // - var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend); + var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.girlfriend); if (girlfriend != null) { girlfriend.characterType = CharacterType.GF; } - else if (currentCharData.girlfriend != '') + else if (currentCharacterData.girlfriend != '') { - trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...'); + trace('WARNING: Could not load girlfriend character with ID ${currentCharacterData.girlfriend}, skipping...'); } else { @@ -1381,7 +1367,7 @@ class PlayState extends MusicBeatSubState // // DAD // - var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent); + var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.opponent); if (dad != null) { @@ -1400,7 +1386,7 @@ class PlayState extends MusicBeatSubState // // BOYFRIEND // - var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayerId); + var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.player); if (boyfriend != null) { @@ -1549,7 +1535,7 @@ class PlayState extends MusicBeatSubState if (!overrideMusic) { - vocals = currentChart.buildVocals(currentPlayerId); + vocals = currentChart.buildVocals(); if (vocals.members.length == 0) { @@ -1893,6 +1879,7 @@ class PlayState extends MusicBeatSubState { // Grant the player health. health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed; + songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed); } // TODO: Potential penalty for dropping a hold note? @@ -2013,103 +2000,6 @@ class PlayState extends MusicBeatSubState } } - /** - * Handle player inputs. - */ - function keyShit(test:Bool):Void - { - // control arrays, order L D R U - var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT]; - var pressArray:Array<Bool> = [ - controls.NOTE_LEFT_P, - controls.NOTE_DOWN_P, - controls.NOTE_UP_P, - controls.NOTE_RIGHT_P - ]; - var releaseArray:Array<Bool> = [ - controls.NOTE_LEFT_R, - controls.NOTE_DOWN_R, - controls.NOTE_UP_R, - controls.NOTE_RIGHT_R - ]; - - // if (pressArray.contains(true)) - // { - // var lol:Array<Int> = cast pressArray; - // inputSpitter.push(Std.int(Conductor.songPosition) + ' ' + lol.join(' ')); - // } - - // HOLDS, check for sustain notes - if (holdArray.contains(true) && generatedMusic) - { - /* - activeNotes.forEachAlive(function(daNote:Note) { - if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData]) goodNoteHit(daNote); - }); - */ - } - - // PRESSES, check for note hits - if (pressArray.contains(true) && generatedMusic) - { - Haptic.vibrate(100, 100); - - if (currentStage != null && currentStage.getBoyfriend() != null) - { - currentStage.getBoyfriend().holdTimer = 0; - } - - var possibleNotes:Array<NoteSprite> = []; // notes that can be hit - var directionList:Array<Int> = []; // directions that can be hit - var dumbNotes:Array<NoteSprite> = []; // notes to kill later - - for (note in dumbNotes) - { - FlxG.log.add('killing dumb ass note at ' + note.noteData.time); - note.kill(); - // activeNotes.remove(note, true); - note.destroy(); - } - - possibleNotes.sort((a, b) -> Std.int(a.noteData.time - b.noteData.time)); - - if (perfectMode) - { - goodNoteHit(possibleNotes[0], null); - } - else if (possibleNotes.length > 0) - { - for (shit in 0...pressArray.length) - { // if a direction is hit that shouldn't be - if (pressArray[shit] && !directionList.contains(shit)) ghostNoteMiss(shit); - } - for (coolNote in possibleNotes) - { - if (pressArray[coolNote.noteData.getDirection()]) goodNoteHit(coolNote, null); - } - } - else - { - // HNGGG I really want to add an option for ghost tapping - // L + ratio - for (shit in 0...pressArray.length) - if (pressArray[shit]) ghostNoteMiss(shit, false); - } - } - - if (currentStage == null) return; - - for (keyId => isPressed in pressArray) - { - if (playerStrumline == null) continue; - - var dir:NoteDirection = Strumline.DIRECTIONS[keyId]; - - if (isPressed && !playerStrumline.isConfirm(dir)) playerStrumline.playPress(dir); - if (!holdArray[keyId]) playerStrumline.playStatic(dir); - } - } - function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void { var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true); @@ -2118,19 +2008,16 @@ class PlayState extends MusicBeatSubState // Calling event.cancelEvent() skips all the other logic! Neat! if (event.eventCanceled) return; - if (!note.isHoldNote) - { - Highscore.tallies.combo++; - Highscore.tallies.totalNotesHit++; + Highscore.tallies.combo++; + Highscore.tallies.totalNotesHit++; - if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo; + if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo; - popUpScore(note, input); - } + popUpScore(note, input); playerStrumline.hitNote(note); - if (note.holdNoteSprite != null) + if (note.isHoldNote && note.holdNoteSprite != null) { playerStrumline.playNoteHoldCover(note.holdNoteSprite); } diff --git a/source/funkin/play/cutscene/dialogue/ConversationData.hx b/source/funkin/play/cutscene/dialogue/ConversationData.hx index 749f1b7a1..8c4aa9684 100644 --- a/source/funkin/play/cutscene/dialogue/ConversationData.hx +++ b/source/funkin/play/cutscene/dialogue/ConversationData.hx @@ -172,9 +172,13 @@ enum abstract BackdropType(String) from String to String class MusicData { public var asset:String; - public var looped:Bool; + public var fadeTime:Float; + @:optional + @:default(false) + public var looped:Bool; + public function new(asset:String, looped:Bool, fadeTime:Float = 0.0) { this.asset = asset; diff --git a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx index c25b3e87f..9f80f8f9b 100644 --- a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx +++ b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx @@ -6,6 +6,7 @@ import funkin.play.cutscene.dialogue.ScriptedConversation; /** * Contains utilities for loading and parsing conversation data. + * TODO: Refactor to use the json2object + BaseRegistry system that actually validates things for you. */ class ConversationDataParser { diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index e32eb8186..d11c7744b 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -11,7 +11,7 @@ import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongRegistry; import funkin.data.song.SongData.SongMetadata; -import funkin.data.song.SongData.SongPlayableChar; +import funkin.data.song.SongData.SongCharacterData; import funkin.data.song.SongData.SongTimeChange; import funkin.data.song.SongData.SongTimeFormat; import funkin.data.IRegistryEntry; @@ -92,6 +92,15 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta _metadata = _data == null ? [] : [_data]; + variations.clear(); + variations.push(Constants.DEFAULT_VARIATION); + + if (_data != null && _data.playData != null) + { + for (vari in _data.playData.songVariations) + variations.push(vari); + } + for (meta in fetchVariationMetadata(id)) _metadata.push(meta); @@ -101,15 +110,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta return; } - variations.clear(); - variations.push('default'); - if (_data != null && _data.playData != null) - { - for (vari in _data.playData.songVariations) - variations.push(vari); - - populateFromMetadata(); - } + populateDifficulties(); } @:allow(funkin.play.song.Song) @@ -128,7 +129,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta result.difficultyIds.clear(); - result.populateFromMetadata(); + result.populateDifficulties(); for (variation => chartData in charts) result.applyChartData(chartData, variation); @@ -144,10 +145,10 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta } /** - * Populate the song data from the provided metadata, - * including data from individual difficulties. Does not load chart data. + * Populate the difficulty data from the provided metadata. + * Does not load chart data (that is triggered later when we want to play the song). */ - function populateFromMetadata():Void + function populateDifficulties():Void { if (_metadata == null || _metadata.length == 0) return; @@ -176,18 +177,11 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta difficulty.generatedBy = metadata.generatedBy; difficulty.stage = metadata.playData.stage; - // difficulty.noteSkin = metadata.playData.noteSkin; + difficulty.noteStyle = metadata.playData.noteSkin; difficulties.set(diffId, difficulty); - difficulty.chars = new Map<String, SongPlayableChar>(); - if (metadata.playData.playableChars == null) continue; - for (charId in metadata.playData.playableChars.keys()) - { - var char:Null<SongPlayableChar> = metadata.playData.playableChars.get(charId); - if (char == null) continue; - difficulty.chars.set(charId, char); - } + difficulty.characters = metadata.playData.characters; } } } @@ -321,7 +315,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta trace('Fetching song metadata for $id'); var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id); if (version == null) return null; - return SongRegistry.instance.parseEntryMetadataWithMigration(id, '', version); + return SongRegistry.instance.parseEntryMetadataWithMigration(id, Constants.DEFAULT_VARIATION, version); } function fetchVariationMetadata(id:String):Array<SongMetadata> @@ -365,19 +359,20 @@ class SongDifficulty */ public var events:Array<SongEventData>; - public var songName:String = SongValidator.DEFAULT_SONGNAME; - public var songArtist:String = SongValidator.DEFAULT_ARTIST; - public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT; - public var divisions:Null<Int> = SongValidator.DEFAULT_DIVISIONS; - public var looped:Bool = SongValidator.DEFAULT_LOOPED; + public var songName:String = Constants.DEFAULT_SONGNAME; + public var songArtist:String = Constants.DEFAULT_ARTIST; + public var timeFormat:SongTimeFormat = Constants.DEFAULT_TIMEFORMAT; + public var divisions:Null<Int> = null; + public var looped:Bool = false; public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY; public var timeChanges:Array<SongTimeChange> = []; - public var stage:String = SongValidator.DEFAULT_STAGE; - public var chars:Map<String, SongPlayableChar> = null; + public var stage:String = Constants.DEFAULT_STAGE; + public var noteStyle:String = Constants.DEFAULT_NOTE_STYLE; + public var characters:SongCharacterData = null; - public var scrollSpeed:Float = SongValidator.DEFAULT_SCROLLSPEED; + public var scrollSpeed:Float = Constants.DEFAULT_SCROLLSPEED; public function new(song:Song, diffId:String, variation:String) { @@ -401,28 +396,24 @@ class SongDifficulty return timeChanges[0].bpm; } - public function getPlayableChar(id:String):Null<SongPlayableChar> - { - if (id == null || id == '') return null; - return chars.get(id); - } - - public function getPlayableChars():Array<String> - { - return chars.keys().array(); - } - public function getEvents():Array<SongEventData> { return cast events; } - public inline function cacheInst(?currentPlayerId:String = null):Void + public function cacheInst(instrumental = ''):Void { - var currentPlayer:Null<SongPlayableChar> = getPlayableChar(currentPlayerId); - if (currentPlayer != null) + if (characters != null) { - FlxG.sound.cache(Paths.inst(this.song.id, currentPlayer.inst)); + if (instrumental != '' && characters.altInstrumentals.contains(instrumental)) + { + FlxG.sound.cache(Paths.inst(this.song.id, instrumental)); + } + else + { + // Fallback to default instrumental. + FlxG.sound.cache(Paths.inst(this.song.id, characters.instrumental)); + } } else { @@ -440,9 +431,9 @@ class SongDifficulty * Cache the vocals for a given character. * @param id The character we are about to play. */ - public inline function cacheVocals(?id:String = 'bf'):Void + public inline function cacheVocals():Void { - for (voice in buildVoiceList(id)) + for (voice in buildVoiceList()) { FlxG.sound.cache(voice); } @@ -454,22 +445,15 @@ class SongDifficulty * * @param id The character we are about to play. */ - public function buildVoiceList(?id:String = 'bf'):Array<String> + public function buildVoiceList():Array<String> { - var playableCharData:SongPlayableChar = getPlayableChar(id); - if (playableCharData == null) - { - trace('Could not find playable char $id for song ${this.song.id}'); - return []; - } - var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; // Automatically resolve voices by removing suffixes. // For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`. - var playerId:String = id; - var voicePlayer:String = Paths.voices(this.song.id, '-$id$suffix'); + var playerId:String = characters.player; + var voicePlayer:String = Paths.voices(this.song.id, '-$playerId$suffix'); while (voicePlayer != null && !Assets.exists(voicePlayer)) { // Remove the last suffix. @@ -479,7 +463,7 @@ class SongDifficulty voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); } - var opponentId:String = playableCharData.opponent; + var opponentId:String = characters.opponent; var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix'); while (voiceOpponent != null && !Assets.exists(voiceOpponent)) { @@ -505,11 +489,11 @@ class SongDifficulty * @param charId The player ID. * @return The generated vocal group. */ - public function buildVocals(charId:String = 'bf'):VoicesGroup + public function buildVocals():VoicesGroup { var result:VoicesGroup = new VoicesGroup(); - var voiceList:Array<String> = buildVoiceList(charId); + var voiceList:Array<String> = buildVoiceList(); if (voiceList.length == 0) { diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx deleted file mode 100644 index 43393fa4e..000000000 --- a/source/funkin/play/song/SongMigrator.hx +++ /dev/null @@ -1,256 +0,0 @@ -package funkin.play.song; - -import funkin.play.song.formats.FNFLegacy; -import funkin.data.song.SongData.SongChartData; -import funkin.data.song.SongData.SongEventData; -import funkin.data.song.SongData.SongMetadata; -import funkin.data.song.SongData.SongNoteData; -import funkin.data.song.SongData.SongPlayableChar; -import funkin.util.VersionUtil; - -class SongMigrator -{ - /** - * The current latest version string for the song data format. - * Handle breaking changes by incrementing this value - * and adding migration to the SongMigrator class. - */ - public static final CHART_VERSION:String = '2.0.0'; - - /** - * Version rule for which chart versions are compatible with the current version. - */ - public static final CHART_VERSION_RULE:String = '2.0.x'; - - /** - * Migrate song data from an older chart version to the current version. - * @param jsonData The song metadata to migrate. - * @param songId The ID of the song (only used for error reporting). - * @return The migrated song metadata, or null if the migration failed. - */ - public static function migrateSongMetadata(jsonData:Dynamic, songId:String):SongMetadata - { - if (jsonData.version != null) - { - if (VersionUtil.validateVersionStr(jsonData.version, CHART_VERSION_RULE)) - { - trace('Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.'); - - var songMetadata:SongMetadata = cast jsonData; - - return songMetadata; - } - else - { - trace('Song (${songId}) metadata version (${jsonData.version}) is outdated.'); - switch (jsonData.version) - { - case '1.0.0': - return migrateSongMetadataFromLegacy(jsonData); - default: - trace('Song (${songId}) has unknown metadata version (${jsonData.version}), assuming FNF Legacy.'); - return migrateSongMetadataFromLegacy(jsonData); - } - } - } - else - { - trace('Song metadata version is missing.'); - } - return null; - } - - /** - * Migrate song chart data from an older chart version to the current version. - * @param jsonData The song chart data to migrate. - * @param songId The ID of the song (only used for error reporting). - * @return The migrated song chart data, or null if the migration failed. - */ - public static function migrateSongChartData(jsonData:Dynamic, songId:String):SongChartData - { - if (jsonData.version) - { - if (VersionUtil.validateVersionStr(jsonData.version, CHART_VERSION_RULE)) - { - trace('Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.'); - - var songChartData:SongChartData = cast jsonData; - - return songChartData; - } - else - { - trace('Song (${songId}) chart version (${jsonData.version}) is outdated.'); - switch (jsonData.version) - { - // TODO: Add migration functions as cases here. - default: - // Unknown version. - trace('Song (${songId}) unknown chart version: ${jsonData.version}'); - } - } - } - else - { - trace('Song chart version is missing.'); - } - return null; - } - - /** - * Migrate song metadata from FNF Legacy chart version to the current version. - * @param jsonData The song metadata to migrate. - * @param songId The ID of the song (only used for error reporting). - * @return The migrated song metadata, or null if the migration failed. - */ - public static function migrateSongMetadataFromLegacy(jsonData:Dynamic, difficulty:String = 'normal'):SongMetadata - { - trace('Migrating song metadata from FNF Legacy.'); - - var songData:FNFLegacy = cast jsonData; - - var songMetadata:SongMetadata = new SongMetadata('Import', 'Kawai Sprite', 'default'); - - var hadError:Bool = false; - - // Set generatedBy string for debugging. - songMetadata.generatedBy = 'Chart Editor Import (FNF Legacy)'; - - try - { - // Set the song's BPM. - songMetadata.timeChanges[0].bpm = songData.song.bpm; - } - catch (e) - { - trace("Couldn't parse BPM!"); - hadError = true; - } - - try - { - // Set the song's stage. - songMetadata.playData.stage = songData.song.stageDefault; - } - catch (e) - { - trace("Couldn't parse stage!"); - hadError = true; - } - - try - { - // Set's the song's name. - songMetadata.songName = songData.song.song; - } - catch (e) - { - trace("Couldn't parse song name!"); - hadError = true; - } - - songMetadata.playData.difficulties = []; - if (songData.song != null && songData.song.notes != null) - { - if (Std.isOfType(songData.song.notes, Array)) - { - // One difficulty of notes. - songMetadata.playData.difficulties.push(difficulty); - } - else - { - // Multiple difficulties of notes. - var songNoteDataDynamic:haxe.DynamicAccess<Dynamic> = cast songData.song.notes; - for (difficultyKey in songNoteDataDynamic.keys()) - { - songMetadata.playData.difficulties.push(difficultyKey); - } - } - } - else - { - trace("Couldn't parse difficulties!"); - hadError = true; - } - - songMetadata.playData.songVariations = []; - - // Set the song's song variations. - songMetadata.playData.playableChars = []; - try - { - songMetadata.playData.playableChars.set(songData.song.player1, new SongPlayableChar('', songData.song.player2)); - } - catch (e) - { - trace("Couldn't parse characters!"); - hadError = true; - } - - return songMetadata; - } - - /** - * Migrate song chart data from FNF Legacy chart version to the current version. - * @param jsonData The song data to migrate. - * @param songId The ID of the song (only used for error reporting). - * @param difficulty The difficulty to migrate. - * @return The migrated song chart data, or null if the migration failed. - */ - public static function migrateSongChartDataFromLegacy(jsonData:Dynamic, difficulty:String = 'normal'):SongChartData - { - trace('Migrating song chart data from FNF Legacy.'); - - var songData:FNFLegacy = cast jsonData; - - var songChartData:SongChartData = new SongChartData(["normal" => 1.0], [], ["normal" => []]); - - var songEventsEmpty:Bool = songChartData.getEvents() == null || songChartData.getEvents().length == 0; - if (songEventsEmpty) songChartData.setEvents(migrateSongEventDataFromLegacy(songData.song.notes)); - songChartData.setNotes(migrateSongNoteDataFromLegacy(songData.song.notes), difficulty); - songChartData.setScrollSpeed(songData.song.speed, difficulty); - - return songChartData; - } - - static function migrateSongNoteDataFromLegacy(sections:Array<LegacyNoteSection>):Array<SongNoteData> - { - var songNotes:Array<SongNoteData> = []; - - for (section in sections) - { - // Skip empty sections. - if (section.sectionNotes.length == 0) continue; - - for (note in section.sectionNotes) - { - songNotes.push(new SongNoteData(note.time, note.getData(section.mustHitSection), note.length, note.kind)); - } - } - - return songNotes; - } - - static function migrateSongEventDataFromLegacy(sections:Array<LegacyNoteSection>):Array<SongEventData> - { - var songEvents:Array<SongEventData> = []; - - var lastSectionWasMustHit:Null<Bool> = null; - for (section in sections) - { - // Skip empty sections. - if (section.sectionNotes.length == 0) continue; - - if (section.mustHitSection != lastSectionWasMustHit) - { - lastSectionWasMustHit = section.mustHitSection; - - var firstNote:LegacyNote = section.sectionNotes[0]; - - songEvents.push(new SongEventData(firstNote.time, 'FocusCamera', {char: section.mustHitSection ? 0 : 1})); - } - } - - return songEvents; - } -} diff --git a/source/funkin/play/song/SongSerializer.hx b/source/funkin/play/song/SongSerializer.hx index a0a468c5b..10296e5b4 100644 --- a/source/funkin/play/song/SongSerializer.hx +++ b/source/funkin/play/song/SongSerializer.hx @@ -3,14 +3,14 @@ package funkin.play.song; import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongMetadata; import funkin.util.SerializerUtil; +import funkin.util.FileUtil; import lime.utils.Bytes; import openfl.events.Event; import openfl.events.IOErrorEvent; import openfl.net.FileReference; /** - * Utilities for exporting a chart to a JSON file. - * Primarily used for the chart editor. + * TODO: Refactor and remove this. */ class SongSerializer { @@ -20,7 +20,7 @@ class SongSerializer */ public static function importSongChartDataSync(path:String):SongChartData { - var fileData = readFile(path); + var fileData = FileUtil.readStringFromPath(path); if (fileData == null) return null; @@ -35,7 +35,7 @@ class SongSerializer */ public static function importSongMetadataSync(path:String):SongMetadata { - var fileData = readFile(path); + var fileData = FileUtil.readStringFromPath(path); if (fileData == null) return null; @@ -50,7 +50,7 @@ class SongSerializer */ public static function importSongChartDataAsync(callback:SongChartData->Void):Void { - browseFileReference(function(fileReference:FileReference) { + FileUtil.browseFileReference(function(fileReference:FileReference) { var data = fileReference.data.toString(); if (data == null) return; @@ -67,7 +67,7 @@ class SongSerializer */ public static function importSongMetadataAsync(callback:SongMetadata->Void):Void { - browseFileReference(function(fileReference:FileReference) { + FileUtil.browseFileReference(function(fileReference:FileReference) { var data = fileReference.data.toString(); if (data == null) return; @@ -77,126 +77,4 @@ class SongSerializer if (songMetadata != null) callback(songMetadata); }); } - - /** - * Save a SongChartData object as a JSON file to an automatically generated path. - * Works great on HTML5 and desktop. - */ - public static function exportSongChartData(data:SongChartData, songId:String) - { - var path = '${songId}-chart.json'; - exportSongChartDataAs(path, data); - } - - /** - * Save a SongMetadata object as a JSON file to an automatically generated path. - * Works great on HTML5 and desktop. - */ - public static function exportSongMetadata(data:SongMetadata, songId:String) - { - var path = '${songId}-metadata.json'; - exportSongMetadataAs(path, data); - } - - /** - * Save a SongChartData object as a JSON file to a specified path. - * Works great on HTML5 and desktop. - * - * @param path The file path to save to. - */ - public static function exportSongChartDataAs(path:String, data:SongChartData) - { - var dataString = SerializerUtil.toJSON(data); - - writeFileReference(path, dataString); - } - - /** - * Save a SongMetadata object as a JSON file to a specified path. - * Works great on HTML5 and desktop. - * - * @param path The file path to save to. - */ - public static function exportSongMetadataAs(path:String, data:SongMetadata) - { - var dataString = SerializerUtil.toJSON(data); - - writeFileReference(path, dataString); - } - - /** - * Read the string contents of a file. - * Only works on desktop platforms. - * @param path The file path to read from. - */ - static function readFile(path:String):String - { - #if sys - var fileBytes:Bytes = sys.io.File.getBytes(path); - - if (fileBytes == null) return null; - - return fileBytes.toString(); - #end - - trace('ERROR: readFile not implemented for this platform'); - return null; - } - - /** - * Write string contents to a file. - * Only works on desktop platforms. - * @param path The file path to read from. - */ - static function writeFile(path:String, data:String):Void - { - #if sys - sys.io.File.saveContent(path, data); - return; - #end - trace('ERROR: writeFile not implemented for this platform'); - return; - } - - /** - * Browse for a file to read and execute a callback once we have a file reference. - * Works great on HTML5 or desktop. - * - * @param callback The function to call when the file is loaded. - */ - static function browseFileReference(callback:FileReference->Void) - { - var file = new FileReference(); - - file.addEventListener(Event.SELECT, function(e) { - var selectedFileRef:FileReference = e.target; - trace('Selected file: ' + selectedFileRef.name); - selectedFileRef.addEventListener(Event.COMPLETE, function(e) { - var loadedFileRef:FileReference = e.target; - trace('Loaded file: ' + loadedFileRef.name); - callback(loadedFileRef); - }); - selectedFileRef.load(); - }); - - file.browse(); - } - - /** - * Prompts the user to save a file to their computer. - */ - static function writeFileReference(path:String, data:String) - { - var file = new FileReference(); - file.addEventListener(Event.COMPLETE, function(e:Event) { - trace('Successfully wrote file.'); - }); - file.addEventListener(Event.CANCEL, function(e:Event) { - trace('Cancelled writing file.'); - }); - file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) { - trace('IO error writing file.'); - }); - file.save(data, path); - } } diff --git a/source/funkin/play/song/SongValidator.hx b/source/funkin/play/song/SongValidator.hx deleted file mode 100644 index e33ddd87c..000000000 --- a/source/funkin/play/song/SongValidator.hx +++ /dev/null @@ -1,149 +0,0 @@ -package funkin.play.song; - -import funkin.data.song.SongRegistry; -import funkin.data.song.SongData.SongChartData; -import funkin.data.song.SongData.SongMetadata; -import funkin.data.song.SongData.SongPlayData; -import funkin.data.song.SongData.SongTimeChange; -import funkin.data.song.SongData.SongTimeFormat; - -/** - * For SongMetadata and SongChartData objects, - * ensures mandatory fields are present and populates optional fields with default values. - */ -class SongValidator -{ - public static final DEFAULT_SONGNAME:String = "Unknown"; - public static final DEFAULT_ARTIST:String = "Unknown"; - public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS; - public static final DEFAULT_DIVISIONS:Null<Int> = null; - public static final DEFAULT_LOOPED:Bool = false; - public static final DEFAULT_STAGE:String = "mainStage"; - public static final DEFAULT_SCROLLSPEED:Float = 1.0; - - public static var DEFAULT_GENERATEDBY(get, never):String; - - static function get_DEFAULT_GENERATEDBY():String - { - return '${Constants.TITLE} - ${Constants.VERSION}'; - } - - /** - * Validates the fields of a SongMetadata object (excluding the version field). - * - * @param input The SongMetadata object to validate. - * @param songId The ID of the song being validated. Only used for error messages. - * @return The validated SongMetadata object. - */ - public static function validateSongMetadata(input:SongMetadata, songId:String = 'unknown'):SongMetadata - { - if (input == null) - { - trace('[SONGDATA] Could not parse metadata for song ${songId}'); - return null; - } - - if (input.songName == null) - { - trace('[SONGDATA] Song ${songId} is missing a songName field. '); - input.songName = DEFAULT_SONGNAME; - } - if (input.artist == null) - { - trace('[SONGDATA] Song ${songId} is missing an artist field. '); - input.artist = DEFAULT_ARTIST; - } - if (input.timeFormat == null) - { - trace('[SONGDATA] Song ${songId} is missing a timeFormat field. '); - input.timeFormat = DEFAULT_TIMEFORMAT; - } - if (input.generatedBy == null) - { - input.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; - } - - input.timeChanges = validateTimeChanges(input.timeChanges, songId); - if (input.timeChanges == null) - { - trace('[SONGDATA] Song ${songId} is missing a timeChanges field. '); - return null; - } - - input.playData = validatePlayData(input.playData, songId); - - if (input.variation == null) input.variation = ''; - - return input; - } - - /** - * Validates the fields of a SongPlayData object. - * - * @param input The SongPlayData object to validate. - * @param songId The ID of the song being validated. Only used for error messages. - * @return The validated SongPlayData object. - */ - public static function validatePlayData(input:SongPlayData, songId:String = 'unknown'):SongPlayData - { - if (input == null) - { - trace('[SONGDATA] Could not parse metadata.playData for song ${songId}'); - return null; - } - - return input; - } - - /** - * Validates the fields of a TimeChange object. - * - * @param input The TimeChange object to validate. - * @param songId The ID of the song being validated. Only used for error messages. - * @return The validated TimeChange object. - */ - public static function validateTimeChange(input:SongTimeChange, songId:String = 'unknown'):SongTimeChange - { - if (input == null) - { - trace('[SONGDATA] Could not parse metadata.timeChange for song ${songId}'); - return null; - } - - return input; - } - - /** - * Validates multiple TimeChange objects in an array. - */ - public static function validateTimeChanges(input:Array<SongTimeChange>, songId:String = 'unknown'):Array<SongTimeChange> - { - if (input == null) - { - trace('[SONGDATA] Could not parse metadata.timeChange for song ${songId}'); - return null; - } - - input = input.map((timeChange) -> validateTimeChange(timeChange, songId)); - - return input; - } - - /** - * Validates the fields of a SongChartData object (excluding the version field). - * - * @param input The SongChartData object to validate. - * @param songId The ID of the song being validated. Only used for error messages. - * @return The validated SongChartData object. - */ - public static function validateSongChartData(input:SongChartData, songId:String = 'unknown'):SongChartData - { - if (input == null) - { - trace('[SONGDATA] Could not parse chart data for song ${songId}'); - return null; - } - - return input; - } -} diff --git a/source/funkin/play/song/formats/FNFLegacy.hx b/source/funkin/play/song/formats/FNFLegacy.hx deleted file mode 100644 index a64e461bd..000000000 --- a/source/funkin/play/song/formats/FNFLegacy.hx +++ /dev/null @@ -1,131 +0,0 @@ -package funkin.play.song.formats; - -typedef FNFLegacy = -{ - var song:LegacySongData; -} - -typedef LegacySongData = -{ - var player1:String; // Boyfriend - var player2:String; // Opponent - - var speed:Float; - var stageDefault:String; - var bpm:Float; - var notes:Array<LegacyNoteSection>; - var song:String; // Song name -}; - -typedef LegacyScrollSpeeds = -{ - var easy:Float; - var normal:Float; - var hard:Float; -}; - -typedef LegacyNoteData = -{ - /** - * The easy difficulty. - */ - var ?easy:Array<LegacyNoteSection>; - - /** - * The normal difficulty. - */ - var ?normal:Array<LegacyNoteSection>; - - /** - * The hard difficulty. - */ - var ?hard:Array<LegacyNoteSection>; -}; - -typedef LegacyNoteSection = -{ - /** - * Whether the section is a must-hit section. - * If true, 0-3 are boyfriends notes, 4-7 are opponents notes. - * If false, 0-3 are opponents notes, 4-7 are boyfriends notes. - */ - var mustHitSection:Bool; - - /** - * Array of note data: - * - Direction - * - Time (ms) - * - Sustain Duration (ms) - * - Note kind (true = "alt", or string) - */ - var sectionNotes:Array<LegacyNote>; - - var typeOfSection:Int; - var lengthInSteps:Int; -} - -/** - * Notes in the old format are stored as an Array<Dynamic> - */ -abstract LegacyNote(Array<Dynamic>) -{ - public var time(get, set):Float; - - function get_time():Float - { - return this[0]; - } - - function set_time(value:Float):Float - { - return this[0] = value; - } - - public var data(get, set):Int; - - function get_data():Int - { - return this[1]; - } - - function set_data(value:Int):Int - { - return this[1] = value; - } - - public function getData(mustHitSection:Bool):Int - { - if (mustHitSection) return this[1]; - - return (this[1] + 4) % 8; - } - - public var length(get, set):Float; - - function get_length():Float - { - if (this.length < 3) return 0.0; - return this[2]; - } - - function set_length(value:Float):Float - { - return this[2] = value; - } - - public var kind(get, set):String; - - function get_kind():String - { - if (this.length < 4) return 'normal'; - - if (Std.isOfType(this[3], Bool)) return this[3] ? 'alt' : 'normal'; - - return this[3]; - } - - function set_kind(value:String):String - { - return this[3] = value; - } -} diff --git a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx new file mode 100644 index 000000000..e852dff0a --- /dev/null +++ b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx @@ -0,0 +1,170 @@ +package funkin.ui.debug.charting; + +import openfl.utils.Assets; +import flixel.system.FlxAssets.FlxSoundAsset; +import flixel.system.FlxSound; +import funkin.play.character.BaseCharacter.CharacterType; +import flixel.system.FlxSound; +import haxe.io.Path; + +/** + * Functions for loading audio for the chart editor. + */ +@:nullSafety +@:allow(funkin.ui.debug.charting.ChartEditorState) +@:allow(funkin.ui.debug.charting.ChartEditorDialogHandler) +@:allow(funkin.ui.debug.charting.ChartEditorImportExportHandler) +class ChartEditorAudioHandler +{ + /** + * Loads a vocal track from an absolute file path. + * @param path The absolute path to the audio file. + * @param charKey The character to load the vocal track for. + * @return Success or failure. + */ + static function loadVocalsFromPath(state:ChartEditorState, path:Path, charKey:String = 'default'):Bool + { + #if sys + var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString()); + return loadVocalsFromBytes(state, fileBytes, charKey); + #else + trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); + return false; + #end + } + + /** + * Load a vocal track for a given song and character and add it to the voices group. + * + * @param path ID of the asset. + * @param charKey Character to load the vocal track for. + * @return Success or failure. + */ + static function loadVocalsFromAsset(state:ChartEditorState, path:String, charType:CharacterType = OTHER):Bool + { + var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false); + if (vocalTrack != null) + { + switch (charType) + { + case CharacterType.BF: + if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addPlayerVoice(vocalTrack); + state.audioVocalTrackData.set(state.currentSongCharacterPlayer, Assets.getBytes(path)); + case CharacterType.DAD: + if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); + state.audioVocalTrackData.set(state.currentSongCharacterOpponent, Assets.getBytes(path)); + default: + if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack); + state.audioVocalTrackData.set('default', Assets.getBytes(path)); + } + + return true; + } + return false; + } + + /** + * Loads a vocal track from audio byte data. + */ + static function loadVocalsFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, charKey:String = ''):Bool + { + var openflSound:openfl.media.Sound = new openfl.media.Sound(); + openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); + var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false); + if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack); + state.audioVocalTrackData.set(charKey, bytes); + return true; + } + + /** + * Loads an instrumental from an absolute file path, replacing the current instrumental. + * + * @param path The absolute path to the audio file. + * + * @return Success or failure. + */ + static function loadInstrumentalFromPath(state:ChartEditorState, path:Path):Bool + { + #if sys + // Validate file extension. + if (path.ext != null && !ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext)) + { + return false; + } + + var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString()); + return loadInstrumentalFromBytes(state, fileBytes, '${path.file}.${path.ext}'); + #else + trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); + return false; + #end + } + + /** + * Loads an instrumental from audio byte data, replacing the current instrumental. + * @param bytes The audio byte data. + * @param fileName The name of the file, if available. Used for notifications. + * @return Success or failure. + */ + static function loadInstrumentalFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, fileName:String = null):Bool + { + if (bytes == null) + { + return false; + } + + var openflSound:openfl.media.Sound = new openfl.media.Sound(); + openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); + state.audioInstTrack = FlxG.sound.load(openflSound, 1.0, false); + state.audioInstTrack.autoDestroy = false; + state.audioInstTrack.pause(); + + state.audioInstTrackData = bytes; + + state.postLoadInstrumental(); + + return true; + } + + /** + * Loads an instrumental from an OpenFL asset, replacing the current instrumental. + * @param path The path to the asset. Use `Paths` to build this. + * @return Success or failure. + */ + static function loadInstrumentalFromAsset(state:ChartEditorState, path:String):Bool + { + var instTrack:FlxSound = FlxG.sound.load(path, 1.0, false); + if (instTrack != null) + { + state.audioInstTrack = instTrack; + + state.audioInstTrackData = Assets.getBytes(path); + + state.postLoadInstrumental(); + return true; + } + + return false; + } + + /** + * Play a sound effect. + * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance. + */ + public static function playSound(path:String):Void + { + var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound(); + + var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path); + if (asset == null) + { + trace('WARN: Failed to play sound $path, asset not found.'); + return; + } + + snd.loadEmbedded(asset); + snd.autoDestroy = true; + FlxG.sound.list.add(snd); + snd.play(); + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx index 79f58a098..c358c1d3d 100644 --- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx +++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx @@ -64,7 +64,7 @@ class AddNotesCommand implements ChartEditorCommand state.currentEventSelection = []; } - state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -78,7 +78,7 @@ class AddNotesCommand implements ChartEditorCommand state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); state.currentNoteSelection = []; state.currentEventSelection = []; - state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -114,7 +114,7 @@ class RemoveNotesCommand implements ChartEditorCommand state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); state.currentNoteSelection = []; state.currentEventSelection = []; - state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -131,7 +131,7 @@ class RemoveNotesCommand implements ChartEditorCommand } state.currentNoteSelection = notes; state.currentEventSelection = []; - state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -252,7 +252,7 @@ class AddEventsCommand implements ChartEditorCommand state.currentEventSelection = events; } - state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -296,7 +296,7 @@ class RemoveEventsCommand implements ChartEditorCommand { state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); state.currentEventSelection = []; - state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -312,7 +312,7 @@ class RemoveEventsCommand implements ChartEditorCommand state.currentSongChartEventData.push(event); } state.currentEventSelection = events; - state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -352,7 +352,7 @@ class RemoveItemsCommand implements ChartEditorCommand state.currentNoteSelection = []; state.currentEventSelection = []; - state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -376,7 +376,7 @@ class RemoveItemsCommand implements ChartEditorCommand state.currentNoteSelection = notes; state.currentEventSelection = events; - state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.saveDataDirty = true; state.noteDisplayDirty = true; diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx index 6f44f89a2..736851d16 100644 --- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx @@ -1,40 +1,45 @@ package funkin.ui.debug.charting; -import funkin.play.character.CharacterData; -import funkin.util.Constants; -import funkin.util.SerializerUtil; +import funkin.ui.haxeui.components.FunkinDropDown; +import flixel.util.FlxTimer; +import funkin.data.song.importer.FNFLegacyData; +import funkin.data.song.importer.FNFLegacyImporter; +import funkin.data.song.SongData.SongCharacterData; import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongMetadata; -import flixel.util.FlxTimer; -import funkin.ui.haxeui.components.FunkinLink; -import funkin.util.SortUtil; +import funkin.data.song.SongData.SongTimeChange; +import funkin.data.song.SongRegistry; import funkin.input.Cursor; import funkin.play.character.BaseCharacter; +import funkin.play.character.CharacterData; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.song.Song; -import funkin.play.song.SongMigrator; -import funkin.play.song.SongValidator; -import funkin.data.song.SongRegistry; -import funkin.data.song.SongData.SongPlayableChar; -import funkin.data.song.SongData.SongTimeChange; +import funkin.play.stage.StageData; +import funkin.ui.haxeui.components.FunkinLink; +import funkin.util.Constants; import funkin.util.FileUtil; +import funkin.util.SerializerUtil; +import funkin.util.SortUtil; +import funkin.util.VersionUtil; import haxe.io.Path; import haxe.ui.components.Button; import haxe.ui.components.DropDown; import haxe.ui.components.Label; import haxe.ui.components.Link; import haxe.ui.components.NumberStepper; +import haxe.ui.components.Slider; import haxe.ui.components.TextField; import haxe.ui.containers.Box; import haxe.ui.containers.dialogs.Dialog; +import haxe.ui.containers.dialogs.Dialog.DialogButton; import haxe.ui.containers.dialogs.Dialogs; -import haxe.ui.containers.properties.PropertyGrid; -import haxe.ui.containers.properties.PropertyGroup; +import haxe.ui.containers.Form; import haxe.ui.containers.VBox; import haxe.ui.core.Component; import haxe.ui.events.UIEvent; import haxe.ui.notifications.NotificationManager; import haxe.ui.notifications.NotificationType; +import thx.semver.Version; using Lambda; @@ -48,13 +53,14 @@ class ChartEditorDialogHandler static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT:String = Paths.ui('chart-editor/dialogs/welcome'); static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-inst'); static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata'); - static final CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata-chargroup'); static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals'); static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals-entry'); static final CHART_EDITOR_DIALOG_OPEN_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart'); static final CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-entry'); static final CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/import-chart'); static final CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT:String = Paths.ui('chart-editor/dialogs/user-guide'); + static final CHART_EDITOR_DIALOG_ADD_VARIATION_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-variation'); + static final CHART_EDITOR_DIALOG_ADD_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-difficulty'); /** * Builds and opens a dialog giving brief credits for the chart editor. @@ -83,6 +89,7 @@ class ChartEditorDialogHandler linkCreateBasic.onClick = function(_event) { // Hide the welcome dialog dialog.hideDialog(DialogButton.CANCEL); + state.stopWelcomeMusic(); // // Create Song Wizard @@ -95,6 +102,7 @@ class ChartEditorDialogHandler linkImportChartLegacy.onClick = function(_event) { // Hide the welcome dialog dialog.hideDialog(DialogButton.CANCEL); + state.stopWelcomeMusic(); // Open the "Import Chart" dialog openImportChartWizard(state, 'legacy', false); @@ -105,6 +113,7 @@ class ChartEditorDialogHandler buttonBrowse.onClick = function(_event) { // Hide the welcome dialog dialog.hideDialog(DialogButton.CANCEL); + state.stopWelcomeMusic(); // Open the "Open Chart" dialog openBrowseWizard(state, false); @@ -133,14 +142,16 @@ class ChartEditorDialogHandler linkTemplateSong.text = songName; linkTemplateSong.onClick = function(_event) { dialog.hideDialog(DialogButton.CANCEL); + state.stopWelcomeMusic(); // Load song from template - state.loadSongAsTemplate(targetSongId); + ChartEditorImportExportHandler.loadSongAsTemplate(state, targetSongId); } splashTemplateContainer.addComponent(linkTemplateSong); } + state.fadeInWelcomeMusic(); return dialog; } @@ -298,7 +309,7 @@ class ChartEditorDialogHandler {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) { if (selectedFile != null && selectedFile.bytes != null) { - if (state.loadInstrumentalFromBytes(selectedFile.bytes)) + if (ChartEditorAudioHandler.loadInstrumentalFromBytes(state, selectedFile.bytes)) { trace('Selected file: ' + selectedFile.fullPath); #if !mac @@ -335,7 +346,7 @@ class ChartEditorDialogHandler onDropFile = function(pathStr:String) { var path:Path = new Path(pathStr); trace('Dropped file (${path})'); - if (state.loadInstrumentalFromPath(path)) + if (ChartEditorAudioHandler.loadInstrumentalFromPath(state, path)) { // Tell the user the load was successful. #if !mac @@ -457,62 +468,96 @@ class ChartEditorDialogHandler dialog.hideDialog(DialogButton.CANCEL); } - var dialogSongName:Null<TextField> = dialog.findComponent('dialogSongName', TextField); - if (dialogSongName == null) throw 'Could not locate dialogSongName TextField in Song Metadata dialog'; - dialogSongName.onChange = function(event:UIEvent) { + var newSongMetadata:SongMetadata = new SongMetadata('', '', 'default'); + + var inputSongName:Null<TextField> = dialog.findComponent('inputSongName', TextField); + if (inputSongName == null) throw 'Could not locate inputSongName TextField in Song Metadata dialog'; + inputSongName.onChange = function(event:UIEvent) { var valid:Bool = event.target.text != null && event.target.text != ''; if (valid) { - dialogSongName.removeClass('invalid-value'); - state.currentSongMetadata.songName = event.target.text; + inputSongName.removeClass('invalid-value'); + newSongMetadata.songName = event.target.text; } else { - state.currentSongMetadata.songName = ""; + newSongMetadata.songName = ""; } }; - state.currentSongMetadata.songName = ""; + inputSongName.text = ""; - var dialogSongArtist:Null<TextField> = dialog.findComponent('dialogSongArtist', TextField); - if (dialogSongArtist == null) throw 'Could not locate dialogSongArtist TextField in Song Metadata dialog'; - dialogSongArtist.onChange = function(event:UIEvent) { + var inputSongArtist:Null<TextField> = dialog.findComponent('inputSongArtist', TextField); + if (inputSongArtist == null) throw 'Could not locate inputSongArtist TextField in Song Metadata dialog'; + inputSongArtist.onChange = function(event:UIEvent) { var valid:Bool = event.target.text != null && event.target.text != ''; if (valid) { - dialogSongArtist.removeClass('invalid-value'); - state.currentSongMetadata.artist = event.target.text; + inputSongArtist.removeClass('invalid-value'); + newSongMetadata.artist = event.target.text; } else { - state.currentSongMetadata.artist = ""; + newSongMetadata.artist = ""; } }; - state.currentSongMetadata.artist = ""; + inputSongArtist.text = ""; - var dialogStage:Null<DropDown> = dialog.findComponent('dialogStage', DropDown); - if (dialogStage == null) throw 'Could not locate dialogStage DropDown in Song Metadata dialog'; - dialogStage.onChange = function(event:UIEvent) { + var inputStage:Null<DropDown> = dialog.findComponent('inputStage', DropDown); + if (inputStage == null) throw 'Could not locate inputStage DropDown in Song Metadata dialog'; + inputStage.onChange = function(event:UIEvent) { if (event.data == null && event.data.id == null) return; - state.currentSongMetadata.playData.stage = event.data.id; + newSongMetadata.playData.stage = event.data.id; }; - state.currentSongMetadata.playData.stage = 'mainStage'; + var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, newSongMetadata.playData.stage); + inputStage.value = startingValueStage; - var dialogNoteSkin:Null<DropDown> = dialog.findComponent('dialogNoteSkin', DropDown); - if (dialogNoteSkin == null) throw 'Could not locate dialogNoteSkin DropDown in Song Metadata dialog'; - dialogNoteSkin.onChange = function(event:UIEvent) { + var inputNoteStyle:Null<FunkinDropDown> = dialog.findComponent('inputNoteStyle', FunkinDropDown); + if (inputNoteStyle == null) throw 'Could not locate inputNoteStyle DropDown in Song Metadata dialog'; + inputNoteStyle.onChange = function(event:UIEvent) { if (event.data.id == null) return; - state.currentSongNoteSkin = event.data.id; + newSongMetadata.playData.noteSkin = event.data.id; }; - state.currentSongNoteSkin = 'funkin'; + var startingValueNoteStyle = ChartEditorDropdowns.populateDropdownWithNoteStyles(inputNoteStyle, newSongMetadata.playData.noteSkin); + inputNoteStyle.value = startingValueNoteStyle; + + var inputCharacterPlayer:Null<FunkinDropDown> = dialog.findComponent('inputCharacterPlayer', FunkinDropDown); + if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.'; + inputCharacterPlayer.onChange = function(event:UIEvent) { + if (event.data?.id == null) return; + newSongMetadata.playData.characters.player = event.data.id; + }; + var startingValuePlayer = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterPlayer, CharacterType.BF, + newSongMetadata.playData.characters.player); + inputCharacterPlayer.value = startingValuePlayer; + + var inputCharacterOpponent:Null<FunkinDropDown> = dialog.findComponent('inputCharacterOpponent', FunkinDropDown); + if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.'; + inputCharacterOpponent.onChange = function(event:UIEvent) { + if (event.data?.id == null) return; + newSongMetadata.playData.characters.opponent = event.data.id; + }; + var startingValueOpponent = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterOpponent, CharacterType.DAD, + newSongMetadata.playData.characters.opponent); + inputCharacterOpponent.value = startingValueOpponent; + + var inputCharacterGirlfriend:Null<FunkinDropDown> = dialog.findComponent('inputCharacterGirlfriend', FunkinDropDown); + if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.'; + inputCharacterGirlfriend.onChange = function(event:UIEvent) { + if (event.data?.id == null) return; + newSongMetadata.playData.characters.girlfriend = event.data.id == "none" ? "" : event.data.id; + }; + var startingValueGirlfriend = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterGirlfriend, CharacterType.GF, + newSongMetadata.playData.characters.girlfriend); + inputCharacterGirlfriend.value = startingValueGirlfriend; var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper); if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Song Metadata dialog'; dialogBPM.onChange = function(event:UIEvent) { if (event.value == null || event.value <= 0) return; - var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges; + var timeChanges:Array<SongTimeChange> = newSongMetadata.timeChanges; if (timeChanges == null || timeChanges.length == 0) { timeChanges = [new SongTimeChange(0, event.value)]; @@ -524,24 +569,9 @@ class ChartEditorDialogHandler Conductor.forceBPM(event.value); - state.currentSongMetadata.timeChanges = timeChanges; + newSongMetadata.timeChanges = timeChanges; }; - var dialogCharGrid:Null<PropertyGrid> = dialog.findComponent('dialogCharGrid', PropertyGrid); - if (dialogCharGrid == null) throw 'Could not locate dialogCharGrid PropertyGrid in Song Metadata dialog'; - var dialogCharAdd:Null<Button> = dialog.findComponent('dialogCharAdd', Button); - if (dialogCharAdd == null) throw 'Could not locate dialogCharAdd Button in Song Metadata dialog'; - dialogCharAdd.onClick = function(event:UIEvent) { - 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')); - var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button); if (dialogContinue == null) throw 'Could not locate dialogContinue button in Song Metadata dialog'; dialogContinue.onClick = (_event) -> dialog.hideDialog(DialogButton.APPLY); @@ -549,78 +579,6 @@ class ChartEditorDialogHandler return dialog; } - static function buildCharGroup(state:ChartEditorState, key:String = '', removeFunc:Void->Void = null):PropertyGroup - { - var groupKey:String = key; - - var getCharData:Void->Null<SongPlayableChar> = function():Null<SongPlayableChar> { - if (state.currentSongMetadata.playData == null) return null; - 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:String->Void = function(target:String):Void { - var charData:Null<SongPlayableChar> = getCharData(); - if (charData == null) return; - - if (state.currentSongMetadata.playData.playableChars == null) return; - state.currentSongMetadata.playData.playableChars.remove(groupKey); - state.currentSongMetadata.playData.playableChars.set(target, charData); - groupKey = target; - } - - var removeGroup:Void->Void = function():Void { - if (state?.currentSongMetadata?.playData?.playableChars == null) return; - state.currentSongMetadata.playData.playableChars.remove(groupKey); - if (removeFunc != null) removeFunc(); - } - - var charData:Null<SongPlayableChar> = getCharData(); - - var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT); - - var charGroupPlayer:Null<DropDown> = charGroup.findComponent('charGroupPlayer', DropDown); - if (charGroupPlayer == null) throw 'Could not locate charGroupPlayer DropDown in Song Metadata dialog'; - charGroupPlayer.onChange = function(event:UIEvent):Void { - if (charData != null) return; - charGroup.text = event.data.text; - moveCharGroup(event.data.id); - }; - - var charGroupOpponent:Null<DropDown> = charGroup.findComponent('charGroupOpponent', DropDown); - if (charGroupOpponent == null) throw 'Could not locate charGroupOpponent DropDown in Song Metadata dialog'; - charGroupOpponent.onChange = function(event:UIEvent):Void { - if (charData == null) return; - charData.opponent = event.data.id; - }; - charGroupOpponent.value = charData.opponent; - - var charGroupGirlfriend:Null<DropDown> = charGroup.findComponent('charGroupGirlfriend', DropDown); - if (charGroupGirlfriend == null) throw 'Could not locate charGroupGirlfriend DropDown in Song Metadata dialog'; - charGroupGirlfriend.onChange = function(event:UIEvent):Void { - if (charData == null) return; - charData.girlfriend = event.data.id; - }; - charGroupGirlfriend.value = charData.girlfriend; - - var charGroupRemove:Null<Button> = charGroup.findComponent('charGroupRemove', Button); - if (charGroupRemove == null) throw 'Could not locate charGroupRemove Button in Song Metadata dialog'; - charGroupRemove.onClick = function(event:UIEvent):Void { - removeGroup(); - }; - - if (removeFunc == null) charGroupRemove.hidden = true; - - return charGroup; - } - /** * Builds and opens a dialog where the user uploads vocals for the current song. * @param state The current chart editor state. @@ -631,13 +589,10 @@ class ChartEditorDialogHandler { var charIdsForVocals:Array<String> = []; - for (charKey in state.currentSongMetadata.playData.playableChars.keys()) - { - var charData:Null<SongPlayableChar> = state.currentSongMetadata.playData.playableChars.get(charKey); - if (charData == null) continue; - charIdsForVocals.push(charKey); - if (charData.opponent != null) charIdsForVocals.push(charData.opponent); - } + var charData:SongCharacterData = state.currentSongMetadata.playData.characters; + + charIdsForVocals.push(charData.player); + charIdsForVocals.push(charData.opponent); var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable); if (dialog == null) throw 'Could not locate Upload Vocals dialog'; @@ -678,7 +633,7 @@ class ChartEditorDialogHandler trace('Selected file: $pathStr'); var path:Path = new Path(pathStr); - if (state.loadVocalsFromPath(path, charKey)) + if (ChartEditorAudioHandler.loadVocalsFromPath(state, path, charKey)) { // Tell the user the load was successful. #if !mac @@ -740,7 +695,7 @@ class ChartEditorDialogHandler #else vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}'; #end - state.loadVocalsFromBytes(selectedFile.bytes, charKey); + ChartEditorAudioHandler.loadVocalsFromBytes(state, selectedFile.bytes, charKey); dialogNoVocals.hidden = true; removeDropHandler(onDropFile); } @@ -793,7 +748,7 @@ class ChartEditorDialogHandler var buttonContinue:Null<Button> = dialog.findComponent('dialogContinue', Button); if (buttonContinue == null) throw 'Could not locate dialogContinue button in Open Chart dialog'; buttonContinue.onClick = function(_event) { - state.loadSong(songMetadata, songChartData); + ChartEditorImportExportHandler.loadSong(state, songMetadata, songChartData); dialog.hideDialog(DialogButton.APPLY); } @@ -880,9 +835,26 @@ class ChartEditorDialogHandler var path:Path = new Path(pathStr); trace('Dropped JSON file (${path})'); - var songMetadataJson:Dynamic = FileUtil.readJSONFromPath(path.toString()); - var songMetadataVariation:SongMetadata = SongMigrator.migrateSongMetadata(songMetadataJson, 'import'); - songMetadataVariation = SongValidator.validateSongMetadata(songMetadataVariation, 'import'); + var songMetadataTxt:String = FileUtil.readStringFromPath(path.toString()); + + var songMetadataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songMetadataTxt); + if (songMetadataVersion == null) + { + // Tell the user the load was not successful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Failure', + body: 'Could not parse metadata file version (${path.file}.${path.ext})', + type: NotificationType.Error, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + return; + } + + var songMetadataVariation:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(songMetadataTxt, path.toString(), + songMetadataVersion); if (songMetadataVariation == null) { @@ -928,31 +900,63 @@ class ChartEditorDialogHandler { trace('Selected file: ' + selectedFile.name); - var songMetadataJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes); - var songMetadataVariation:SongMetadata = SongMigrator.migrateSongMetadata(songMetadataJson, 'import'); - songMetadataVariation = SongValidator.validateSongMetadata(songMetadataVariation, 'import'); - songMetadataVariation.variation = variation; + var songMetadataTxt:String = selectedFile.bytes.toString(); - songMetadata.set(variation, songMetadataVariation); + var songMetadataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songMetadataTxt); + if (songMetadataVersion == null) + { + // Tell the user the load was not successful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Failure', + body: 'Could not parse metadata file version (${selectedFile.name})', + type: NotificationType.Error, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + return; + } - // Tell the user the load was successful. - #if !mac - NotificationManager.instance.addNotification( - { - title: 'Success', - body: 'Loaded metadata file (${selectedFile.name})', - type: NotificationType.Success, - expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME - }); - #end + var songMetadataVariation:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(songMetadataTxt, selectedFile.name, + songMetadataVersion); - #if FILE_DROP_SUPPORTED - label.text = 'Metadata file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; - #else - label.text = 'Metadata file (click to browse)\n${selectedFile.name}'; - #end + if (songMetadataVariation != null) + { + songMetadata.set(variation, songMetadataVariation); - if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations); + // Tell the user the load was successful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Success', + body: 'Loaded metadata file (${selectedFile.name})', + type: NotificationType.Success, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + + #if FILE_DROP_SUPPORTED + label.text = 'Metadata file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; + #else + label.text = 'Metadata file (click to browse)\n${selectedFile.name}'; + #end + + if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations); + } + else + { + // Tell the user the load was unsuccessful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Failure', + body: 'Failed to load metadata file (${selectedFile.name})', + type: NotificationType.Error, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + } } }); } @@ -961,31 +965,64 @@ class ChartEditorDialogHandler var path:Path = new Path(pathStr); trace('Dropped JSON file (${path})'); - var songChartDataJson:Dynamic = FileUtil.readJSONFromPath(path.toString()); - var songChartDataVariation:SongChartData = SongMigrator.migrateSongChartData(songChartDataJson, 'import'); - songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import'); + var songChartDataTxt:String = FileUtil.readStringFromPath(path.toString()); - songChartData.set(variation, songChartDataVariation); - state.notePreviewDirty = true; - state.notePreviewViewportBoundsDirty = true; - state.noteDisplayDirty = true; + var songChartDataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songChartDataTxt); + if (songChartDataVersion == null) + { + // Tell the user the load was not successful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Failure', + body: 'Could not parse chart data file version (${path.file}.${path.ext})', + type: NotificationType.Error, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + return; + } - // Tell the user the load was successful. - #if !mac - NotificationManager.instance.addNotification( - { - title: 'Success', - body: 'Loaded chart data file (${path.file}.${path.ext})', - type: NotificationType.Success, - expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME - }); - #end + var songChartDataVariation:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(songChartDataTxt, path.toString(), + songChartDataVersion); - #if FILE_DROP_SUPPORTED - label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}'; - #else - label.text = 'Chart data file (click to browse)\n${path.file}.${path.ext}'; - #end + if (songChartDataVariation != null) + { + songChartData.set(variation, songChartDataVariation); + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.noteDisplayDirty = true; + + // Tell the user the load was successful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Success', + body: 'Loaded chart data file (${path.file}.${path.ext})', + type: NotificationType.Success, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + + #if FILE_DROP_SUPPORTED + label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}'; + #else + label.text = 'Chart data file (click to browse)\n${path.file}.${path.ext}'; + #end + } + else + { + // Tell the user the load was unsuccessful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Failure', + body: 'Failed to load chart data file (${path.file}.${path.ext})', + type: NotificationType.Error, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + } }; onClickChartDataVariation = function(variation:String, label:Label, _event:UIEvent) { @@ -995,31 +1032,51 @@ class ChartEditorDialogHandler { trace('Selected file: ' + selectedFile.name); - var songChartDataJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes); - var songChartDataVariation:SongChartData = SongMigrator.migrateSongChartData(songChartDataJson, 'import'); - songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import'); + var songChartDataTxt:String = selectedFile.bytes.toString(); - songChartData.set(variation, songChartDataVariation); - state.notePreviewDirty = true; - state.notePreviewViewportBoundsDirty = true; - state.noteDisplayDirty = true; + var songChartDataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songChartDataTxt); + if (songChartDataVersion == null) + { + // Tell the user the load was not successful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Failure', + body: 'Could not parse chart data file version (${selectedFile.name})', + type: NotificationType.Error, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + return; + } - // Tell the user the load was successful. - #if !mac - NotificationManager.instance.addNotification( - { - title: 'Success', - body: 'Loaded chart data file (${selectedFile.name})', - type: NotificationType.Success, - expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME - }); - #end + var songChartDataVariation:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(songChartDataTxt, selectedFile.name, + songChartDataVersion); - #if FILE_DROP_SUPPORTED - label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; - #else - label.text = 'Chart data file (click to browse)\n${selectedFile.name}'; - #end + if (songChartDataVariation != null) + { + songChartData.set(variation, songChartDataVariation); + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.noteDisplayDirty = true; + + // Tell the user the load was successful. + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Success', + body: 'Loaded chart data file (${selectedFile.name})', + type: NotificationType.Success, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + + #if FILE_DROP_SUPPORTED + label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; + #else + label.text = 'Chart data file (click to browse)\n${selectedFile.name}'; + #end + } } }); } @@ -1102,11 +1159,27 @@ class ChartEditorDialogHandler if (selectedFile != null && selectedFile.bytes != null) { trace('Selected file: ' + selectedFile.fullPath); - var selectedFileJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes); - var songMetadata:SongMetadata = SongMigrator.migrateSongMetadataFromLegacy(selectedFileJson); - var songChartData:SongChartData = SongMigrator.migrateSongChartDataFromLegacy(selectedFileJson); + var selectedFileTxt:String = selectedFile.bytes.toString(); + var fnfLegacyData:Null<FNFLegacyData> = FNFLegacyImporter.parseLegacyDataRaw(selectedFileTxt, selectedFile.fullPath); - state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]); + if (fnfLegacyData == null) + { + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Failure', + body: 'Failed to parse FNF chart file (${selectedFile.name})', + type: NotificationType.Error, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + return; + } + + var songMetadata:SongMetadata = FNFLegacyImporter.migrateMetadata(fnfLegacyData); + var songChartData:SongChartData = FNFLegacyImporter.migrateChartData(fnfLegacyData); + + ChartEditorImportExportHandler.loadSong(state, [Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]); dialog.hideDialog(DialogButton.APPLY); #if !mac @@ -1124,11 +1197,12 @@ class ChartEditorDialogHandler onDropFile = function(pathStr:String) { var path:Path = new Path(pathStr); - var selectedFileJson:Dynamic = FileUtil.readJSONFromPath(path.toString()); - var songMetadata:SongMetadata = SongMigrator.migrateSongMetadataFromLegacy(selectedFileJson); - var songChartData:SongChartData = SongMigrator.migrateSongChartDataFromLegacy(selectedFileJson); + var selectedFileText:String = FileUtil.readStringFromPath(path.toString()); + var selectedFileData:FNFLegacyData = FNFLegacyImporter.parseLegacyDataRaw(selectedFileText, path.toString()); + var songMetadata:SongMetadata = FNFLegacyImporter.migrateMetadata(selectedFileData); + var songChartData:SongChartData = FNFLegacyImporter.migrateChartData(selectedFileData); - state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]); + ChartEditorImportExportHandler.loadSong(state, [Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]); dialog.hideDialog(DialogButton.APPLY); #if !mac @@ -1181,4 +1255,161 @@ class ChartEditorDialogHandler return dialog; } + + /** + * Builds and opens a dialog where the user can add a new variation for a song. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openAddVariationDialog(state:ChartEditorState, closable:Bool = true):Dialog + { + var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_ADD_VARIATION_LAYOUT, true, false); + if (dialog == null) throw 'Could not locate Add Variation dialog'; + + var variationForm:Null<Form> = dialog.findComponent('variationForm', Form); + if (variationForm == null) throw 'Could not locate variationForm Form in Add Variation dialog'; + + var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button); + if (buttonCancel == null) throw 'Could not locate dialogCancel button in Add Variation dialog'; + buttonCancel.onClick = function(_event) { + dialog.hideDialog(DialogButton.CANCEL); + } + + var buttonAdd:Null<Button> = dialog.findComponent('dialogAdd', Button); + if (buttonAdd == null) throw 'Could not locate dialogAdd button in Add Variation dialog'; + buttonAdd.onClick = function(_event) { + // This performs validation before the onSubmit callback is called. + variationForm.submit(); + } + + var dialogSongName:Null<TextField> = dialog.findComponent('dialogSongName', TextField); + if (dialogSongName == null) throw 'Could not locate dialogSongName TextField in Add Variation dialog'; + dialogSongName.value = state.currentSongMetadata.songName; + + var dialogSongArtist:Null<TextField> = dialog.findComponent('dialogSongArtist', TextField); + if (dialogSongArtist == null) throw 'Could not locate dialogSongArtist TextField in Add Variation dialog'; + dialogSongArtist.value = state.currentSongMetadata.artist; + + var dialogStage:Null<DropDown> = dialog.findComponent('dialogStage', DropDown); + if (dialogStage == null) throw 'Could not locate dialogStage DropDown in Add Variation dialog'; + var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(dialogStage, state.currentSongMetadata.playData.stage); + dialogStage.value = startingValueStage; + + var dialogNoteStyle:Null<DropDown> = dialog.findComponent('dialogNoteStyle', DropDown); + if (dialogNoteStyle == null) throw 'Could not locate dialogNoteStyle DropDown in Add Variation dialog'; + dialogNoteStyle.value = state.currentSongMetadata.playData.noteSkin; + + var dialogCharacterPlayer:Null<DropDown> = dialog.findComponent('dialogCharacterPlayer', DropDown); + if (dialogCharacterPlayer == null) throw 'Could not locate dialogCharacterPlayer DropDown in Add Variation dialog'; + dialogCharacterPlayer.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterPlayer, CharacterType.BF, + state.currentSongMetadata.playData.characters.player); + + var dialogCharacterOpponent:Null<DropDown> = dialog.findComponent('dialogCharacterOpponent', DropDown); + if (dialogCharacterOpponent == null) throw 'Could not locate dialogCharacterOpponent DropDown in Add Variation dialog'; + dialogCharacterOpponent.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterOpponent, CharacterType.DAD, + state.currentSongMetadata.playData.characters.opponent); + + var dialogCharacterGirlfriend:Null<DropDown> = dialog.findComponent('dialogCharacterGirlfriend', DropDown); + if (dialogCharacterGirlfriend == null) throw 'Could not locate dialogCharacterGirlfriend DropDown in Add Variation dialog'; + dialogCharacterGirlfriend.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterGirlfriend, CharacterType.GF, + state.currentSongMetadata.playData.characters.girlfriend); + + var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper); + if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Add Variation dialog'; + dialogBPM.value = state.currentSongMetadata.timeChanges[0].bpm; + + // If all validators succeeded, this callback is called. + + variationForm.onSubmit = function(_event) { + trace('Add Variation dialog submitted, validation succeeded!'); + + var dialogVariationName:Null<TextField> = dialog.findComponent('dialogVariationName', TextField); + if (dialogVariationName == null) throw 'Could not locate dialogVariationName TextField in Add Variation dialog'; + + var pendingVariation:SongMetadata = new SongMetadata(dialogSongName.text, dialogSongArtist.text, dialogVariationName.text.toLowerCase()); + + pendingVariation.playData.stage = dialogStage.value.id; + pendingVariation.playData.noteSkin = dialogNoteStyle.value; + pendingVariation.timeChanges[0].bpm = dialogBPM.value; + + state.songMetadata.set(pendingVariation.variation, pendingVariation); + state.difficultySelectDirty = true; // Force the Difficulty toolbox to update. + #if !mac + NotificationManager.instance.addNotification( + { + title: "Add Variation", + body: 'Added new variation "${pendingVariation.variation}"', + type: NotificationType.Success + }); + #end + dialog.hideDialog(DialogButton.APPLY); + } + + return dialog; + } + + /** + * Builds and opens a dialog where the user can add a new difficulty for a song. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openAddDifficultyDialog(state:ChartEditorState, closable:Bool = true):Dialog + { + var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_ADD_DIFFICULTY_LAYOUT, true, false); + if (dialog == null) throw 'Could not locate Add Difficulty dialog'; + + var difficultyForm:Null<Form> = dialog.findComponent('difficultyForm', Form); + if (difficultyForm == null) throw 'Could not locate difficultyForm Form in Add Difficulty dialog'; + + var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button); + if (buttonCancel == null) throw 'Could not locate dialogCancel button in Add Difficulty dialog'; + buttonCancel.onClick = function(_event) { + dialog.hideDialog(DialogButton.CANCEL); + } + + var buttonAdd:Null<Button> = dialog.findComponent('dialogAdd', Button); + if (buttonAdd == null) throw 'Could not locate dialogAdd button in Add Difficulty dialog'; + buttonAdd.onClick = function(_event) { + // This performs validation before the onSubmit callback is called. + difficultyForm.submit(); + } + + var dialogVariation:Null<DropDown> = dialog.findComponent('dialogVariation', DropDown); + if (dialogVariation == null) throw 'Could not locate dialogVariation DropDown in Add Variation dialog'; + dialogVariation.value = ChartEditorDropdowns.populateDropdownWithVariations(dialogVariation, state, true); + + var labelScrollSpeed:Null<Label> = dialog.findComponent('labelScrollSpeed', Label); + if (labelScrollSpeed == null) throw 'Could not find labelScrollSpeed component.'; + + var inputScrollSpeed:Null<Slider> = dialog.findComponent('inputScrollSpeed', Slider); + if (inputScrollSpeed == null) throw 'Could not find inputScrollSpeed component.'; + inputScrollSpeed.onChange = function(event:UIEvent) { + labelScrollSpeed.text = 'Scroll Speed: ${inputScrollSpeed.value}x'; + }; + inputScrollSpeed.value = state.currentSongChartScrollSpeed; + labelScrollSpeed.text = 'Scroll Speed: ${inputScrollSpeed.value}x'; + + difficultyForm.onSubmit = function(_event) { + trace('Add Difficulty dialog submitted, validation succeeded!'); + + var dialogDifficultyName:Null<TextField> = dialog.findComponent('dialogDifficultyName', TextField); + if (dialogDifficultyName == null) throw 'Could not locate dialogDifficultyName TextField in Add Difficulty dialog'; + + state.createDifficulty(dialogVariation.value.id, dialogDifficultyName.text.toLowerCase(), inputScrollSpeed.value ?? 1.0); + + #if !mac + NotificationManager.instance.addNotification( + { + title: "Add Difficulty", + body: 'Added new difficulty "${dialogDifficultyName.text.toLowerCase()}"', + type: NotificationType.Success + }); + #end + dialog.hideDialog(DialogButton.APPLY); + } + + return dialog; + } } diff --git a/source/funkin/ui/debug/charting/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/ChartEditorDropdowns.hx new file mode 100644 index 000000000..ec41de9c0 --- /dev/null +++ b/source/funkin/ui/debug/charting/ChartEditorDropdowns.hx @@ -0,0 +1,129 @@ +package funkin.ui.debug.charting; + +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; +import funkin.play.stage.StageData; +import funkin.play.stage.StageData.StageDataParser; +import funkin.play.character.CharacterData; +import haxe.ui.components.DropDown; +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.play.character.CharacterData.CharacterDataParser; + +/** + * This class contains functions for populating dropdowns based on game data. + * These get used by both dialogs and toolboxes so they're in their own class to prevent "reaching over." + */ +@:nullSafety +@:access(ChartEditorState) +class ChartEditorDropdowns +{ + public static function populateDropdownWithCharacters(dropDown:DropDown, charType:CharacterType, startingCharId:String):DropDownEntry + { + dropDown.dataSource.clear(); + + // TODO: Filter based on charType. + var charIds:Array<String> = CharacterDataParser.listCharacterIds(); + + var returnValue:DropDownEntry = switch (charType) + { + case BF: {id: "bf", text: "Boyfriend"}; + case DAD: {id: "dad", text: "Daddy Dearest"}; + default: { + dropDown.dataSource.add({id: "none", text: ""}); + {id: "none", text: "None"}; + } + } + + for (charId in charIds) + { + var character:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charId); + if (character == null) continue; + + var value = {id: charId, text: character.name}; + if (startingCharId == charId) returnValue = value; + + dropDown.dataSource.add(value); + } + + dropDown.dataSource.sort('text', ASCENDING); + + return returnValue; + } + + public static function populateDropdownWithStages(dropDown:DropDown, startingStageId:String):DropDownEntry + { + dropDown.dataSource.clear(); + + var stageIds:Array<String> = StageDataParser.listStageIds(); + + var returnValue:DropDownEntry = {id: "mainStage", text: "Main Stage"}; + + for (stageId in stageIds) + { + var stage:Null<StageData> = StageDataParser.parseStageData(stageId); + if (stage == null) continue; + + var value = {id: stageId, text: stage.name}; + if (startingStageId == stageId) returnValue = value; + + dropDown.dataSource.add(value); + } + + dropDown.dataSource.sort('text', ASCENDING); + + return returnValue; + } + + public static function populateDropdownWithNoteStyles(dropDown:DropDown, startingStyleId:String):DropDownEntry + { + dropDown.dataSource.clear(); + + var noteStyleIds:Array<String> = NoteStyleRegistry.instance.listEntryIds(); + + var returnValue:DropDownEntry = {id: "funkin", text: "Funkin'"}; + + for (noteStyleId in noteStyleIds) + { + var noteStyle:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry(noteStyleId); + if (noteStyle == null) continue; + + var value = {id: noteStyleId, text: noteStyle.getName()}; + if (startingStyleId == noteStyleId) returnValue = value; + + dropDown.dataSource.add(value); + } + + dropDown.dataSource.sort('text', ASCENDING); + + return returnValue; + } + + public static function populateDropdownWithVariations(dropDown:DropDown, state:ChartEditorState, includeNone:Bool = true):DropDownEntry + { + dropDown.dataSource.clear(); + + var variationIds:Array<String> = state.availableVariations; + + if (includeNone) + { + dropDown.dataSource.add({id: "none", text: ""}); + } + + var returnValue:DropDownEntry = includeNone ? ({id: "none", text: ""}) : ({id: "default", text: "Default"}); + + for (variationId in variationIds) + { + dropDown.dataSource.add({id: variationId, text: variationId.toTitleCase()}); + } + + dropDown.dataSource.sort('text', ASCENDING); + + return returnValue; + } +} + +typedef DropDownEntry = +{ + id:String, + text:String +}; diff --git a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx new file mode 100644 index 000000000..9ac903e38 --- /dev/null +++ b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx @@ -0,0 +1,195 @@ +package funkin.ui.debug.charting; + +import haxe.ui.notifications.NotificationType; +import funkin.util.DateUtil; +import haxe.io.Path; +import funkin.util.SerializerUtil; +import haxe.ui.notifications.NotificationManager; +import funkin.util.FileUtil; +import funkin.util.FileUtil; +import funkin.play.song.Song; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongRegistry; + +/** + * Contains functions for importing, loading, saving, and exporting charts. + */ +@:nullSafety +@:allow(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorImportExportHandler +{ + /** + * Fetch's a song's existing chart and audio and loads it, replacing the current song. + */ + public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void + { + var song:Null<Song> = SongRegistry.instance.fetchEntry(songId); + + if (song == null) return; + + // Load the song metadata. + var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata(); + var songMetadata:Map<String, SongMetadata> = []; + var songChartData:Map<String, SongChartData> = []; + + for (metadata in rawSongMetadata) + { + if (metadata == null) continue; + var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation; + + // Clone to prevent modifying the original. + var metadataClone:SongMetadata = metadata.clone(variation); + if (metadataClone != null) songMetadata.set(variation, metadataClone); + + var chartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartData(songId, metadata.variation); + if (chartData != null) songChartData.set(variation, chartData); + } + + loadSong(state, songMetadata, songChartData); + + state.sortChartData(); + + state.clearVocals(); + + ChartEditorAudioHandler.loadInstrumentalFromAsset(state, Paths.inst(songId)); + + var diff:Null<SongDifficulty> = song.getDifficulty(state.selectedDifficulty); + var voiceList:Array<String> = diff != null ? diff.buildVoiceList() : []; + if (voiceList.length == 2) + { + ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], BF); + ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], DAD); + } + else + { + for (voicePath in voiceList) + { + ChartEditorAudioHandler.loadVocalsFromAsset(state, voicePath); + } + } + + state.refreshMetadataToolbox(); + + #if !mac + NotificationManager.instance.addNotification( + { + title: 'Success', + body: 'Loaded song (${rawSongMetadata[0].songName})', + type: NotificationType.Success, + expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME + }); + #end + } + + /** + * Loads song metadata and chart data into the editor. + * @param newSongMetadata The song metadata to load. + * @param newSongChartData The song chart data to load. + */ + public static function loadSong(state:ChartEditorState, newSongMetadata:Map<String, SongMetadata>, newSongChartData:Map<String, SongChartData>):Void + { + state.songMetadata = newSongMetadata; + state.songChartData = newSongChartData; + + Conductor.forceBPM(null); // Disable the forced BPM. + Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); + + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.difficultySelectDirty = true; + state.opponentPreviewDirty = true; + state.playerPreviewDirty = true; + + // Remove instrumental and vocal tracks, they will be loaded next. + if (state.audioInstTrack != null) + { + state.audioInstTrack.stop(); + state.audioInstTrack = null; + } + if (state.audioVocalTrackGroup != null) + { + state.audioVocalTrackGroup.stop(); + state.audioVocalTrackGroup.clear(); + } + } + + /** + * @param force Whether to force the export without prompting the user for a file location. + * @param tmp If true, save to the temporary directory instead of the local `backup` directory. + */ + public static function exportAllSongData(state:ChartEditorState, force:Bool = false, tmp:Bool = false):Void + { + var zipEntries:Array<haxe.zip.Entry> = []; + + for (variation in state.availableVariations) + { + var variationId:String = variation; + if (variation == '' || variation == 'default' || variation == 'normal') + { + variationId = ''; + } + + if (variationId == '') + { + var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation); + if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata.json', SerializerUtil.toJSON(variationMetadata))); + var variationChart:Null<SongChartData> = state.songChartData.get(variation); + if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart.json', SerializerUtil.toJSON(variationChart))); + } + else + { + var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation); + if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json', + SerializerUtil.toJSON(variationMetadata))); + var variationChart:Null<SongChartData> = state.songChartData.get(variation); + if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json', + SerializerUtil.toJSON(variationChart))); + } + } + + if (state.audioInstTrackData != null) zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', state.audioInstTrackData)); + for (charId in state.audioVocalTrackData.keys()) + { + var entryData = state.audioVocalTrackData.get(charId); + if (entryData == null) continue; + zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-$charId.ogg', entryData)); + } + + trace('Exporting ${zipEntries.length} files to ZIP...'); + + if (force) + { + var targetPath:String = if (tmp) + { + Path.join([ + FileUtil.getTempDir(), + 'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}' + ]); + } + else + { + Path.join([ + './backups/', + 'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}' + ]); + } + + // 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<String>->Void = function(paths:Array<String>) { + trace('Successfully exported files.'); + }; + + var onCancel:Void->Void = function() { + trace('Export cancelled.'); + }; + + FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '${state.currentSongId}-chart.${Constants.EXT_CHART}'); + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx index 4e0972621..77954087b 100644 --- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx +++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx @@ -19,7 +19,7 @@ class ChartEditorNoteSprite extends FlxSprite /** * The list of available note skin to validate against. */ - public static final NOTE_STYLES:Array<String> = ['Normal', 'Pixel']; + public static final NOTE_STYLES:Array<String> = ['funkin', 'pixel']; /** * The ChartEditorState this note belongs to. @@ -54,20 +54,20 @@ class ChartEditorNoteSprite extends FlxSprite // Initialize all the animations, not just the one we're going to use immediately, // so that later we can reuse the sprite without having to initialize more animations during scrolling. - this.animation.addByPrefix('tapLeftNormal', 'purple instance'); - this.animation.addByPrefix('tapDownNormal', 'blue instance'); - this.animation.addByPrefix('tapUpNormal', 'green instance'); - this.animation.addByPrefix('tapRightNormal', 'red instance'); + this.animation.addByPrefix('tapLeftFunkin', 'purple instance'); + this.animation.addByPrefix('tapDownFunkin', 'blue instance'); + this.animation.addByPrefix('tapUpFunkin', 'green instance'); + this.animation.addByPrefix('tapRightFunkin', 'red instance'); - this.animation.addByPrefix('holdLeftNormal', 'LeftHoldPiece'); - this.animation.addByPrefix('holdDownNormal', 'DownHoldPiece'); - this.animation.addByPrefix('holdUpNormal', 'UpHoldPiece'); - this.animation.addByPrefix('holdRightNormal', 'RightHoldPiece'); + this.animation.addByPrefix('holdLeftFunkin', 'LeftHoldPiece'); + this.animation.addByPrefix('holdDownFunkin', 'DownHoldPiece'); + this.animation.addByPrefix('holdUpFunkin', 'UpHoldPiece'); + this.animation.addByPrefix('holdRightFunkin', 'RightHoldPiece'); - this.animation.addByPrefix('holdEndLeftNormal', 'LeftHoldEnd'); - this.animation.addByPrefix('holdEndDownNormal', 'DownHoldEnd'); - this.animation.addByPrefix('holdEndUpNormal', 'UpHoldEnd'); - this.animation.addByPrefix('holdEndRightNormal', 'RightHoldEnd'); + this.animation.addByPrefix('holdEndLeftFunkin', 'LeftHoldEnd'); + this.animation.addByPrefix('holdEndDownFunkin', 'DownHoldEnd'); + this.animation.addByPrefix('holdEndUpFunkin', 'UpHoldEnd'); + this.animation.addByPrefix('holdEndRightFunkin', 'RightHoldEnd'); this.animation.addByPrefix('tapLeftPixel', 'pixel4'); this.animation.addByPrefix('tapDownPixel', 'pixel5'); @@ -187,8 +187,8 @@ class ChartEditorNoteSprite extends FlxSprite function get_noteStyle():String { - // Fall back to 'Normal' if it's not a valid note style. - return if (NOTE_STYLES.contains(this.parentState.currentSongNoteSkin)) this.parentState.currentSongNoteSkin else 'Normal'; + // Fall back to Funkin' if it's not a valid note style. + return if (NOTE_STYLES.contains(this.parentState.currentSongNoteStyle)) this.parentState.currentSongNoteStyle else 'funkin'; } public function playNoteAnimation():Void @@ -199,7 +199,7 @@ class ChartEditorNoteSprite extends FlxSprite var baseAnimationName:String = 'tap'; // Play the appropriate animation for the type, direction, and skin. - var animationName:String = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteStyle}'; + var animationName:String = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteStyle.toTitleCase()}'; this.animation.play(animationName); @@ -213,7 +213,7 @@ class ChartEditorNoteSprite extends FlxSprite this.updateHitbox(); // TODO: Make this an attribute of the note skin. - this.antialiasing = (this.parentState.currentSongNoteSkin != 'Pixel'); + this.antialiasing = (this.parentState.currentSongNoteStyle != 'Pixel'); } /** diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index add65c5bf..b94041afd 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -1,5 +1,8 @@ package funkin.ui.debug.charting; +import funkin.play.stage.StageData; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.character.CharacterData; import flixel.system.FlxAssets.FlxSoundAsset; import flixel.math.FlxMath; import haxe.ui.components.TextField; @@ -41,7 +44,7 @@ import funkin.data.song.SongRegistry; import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongMetadata; import funkin.data.song.SongData.SongNoteData; -import funkin.data.song.SongData.SongPlayableChar; +import funkin.data.song.SongData.SongCharacterData; import funkin.data.song.SongDataUtils; import funkin.ui.debug.charting.ChartEditorCommand; import funkin.ui.debug.charting.ChartEditorCommand; @@ -88,8 +91,11 @@ using Lambda; // @:nullSafety(Loose) // Enable this while developing, then disable to keep unit tests functional! @:allow(funkin.ui.debug.charting.ChartEditorCommand) +@:allow(funkin.ui.debug.charting.ChartEditorDropdowns) @:allow(funkin.ui.debug.charting.ChartEditorDialogHandler) @:allow(funkin.ui.debug.charting.ChartEditorThemeHandler) +@:allow(funkin.ui.debug.charting.ChartEditorAudioHandler) +@:allow(funkin.ui.debug.charting.ChartEditorImportExportHandler) @:allow(funkin.ui.debug.charting.ChartEditorToolboxHandler) class ChartEditorState extends HaxeUIState { @@ -108,7 +114,6 @@ class ChartEditorState extends HaxeUIState static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata'); static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata'); static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty'); - static final CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:String = Paths.ui('chart-editor/toolbox/characters'); static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview'); static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview'); @@ -751,6 +756,11 @@ class ChartEditorState extends HaxeUIState */ // ============================== + /** + * The chill audio track that plays when you open the Chart Editor. + */ + public var welcomeMusic:FlxSound = new FlxSound(); + /** * The audio track for the instrumental. * `null` until an instrumental track is loaded. @@ -950,19 +960,19 @@ class ChartEditorState extends HaxeUIState return currentSongChartData.events = value; } - public var currentSongNoteSkin(get, set):String; + public var currentSongNoteStyle(get, set):String; - function get_currentSongNoteSkin():String + function get_currentSongNoteStyle():String { if (currentSongMetadata.playData.noteSkin == null) { // Initialize to the default value if not set. - currentSongMetadata.playData.noteSkin = 'Normal'; + currentSongMetadata.playData.noteSkin = 'funkin'; } return currentSongMetadata.playData.noteSkin; } - function set_currentSongNoteSkin(value:String):String + function set_currentSongNoteStyle(value:String):String { return currentSongMetadata.playData.noteSkin = value; } @@ -1025,57 +1035,28 @@ class ChartEditorState extends HaxeUIState return currentSongMetadata.artist = value; } - var currentSongPlayableCharacters(get, never):Array<String>; - - function get_currentSongPlayableCharacters():Array<String> - { - return currentSongMetadata.playData.playableChars.keys().array(); - } - var currentSongCharacterPlayer(get, set):String; function get_currentSongCharacterPlayer():String { - // Validate selected character before returning it. - if (!currentSongPlayableCharacters.contains(selectedCharacter)) - { - trace('Invalid character selected: ' + selectedCharacter); - selectedCharacter = currentSongPlayableCharacters[0]; - } - - return selectedCharacter; + return currentSongMetadata.playData.characters.player; } function set_currentSongCharacterPlayer(value:String):String { - if (!currentSongPlayableCharacters.contains(value)) - { - trace('Invalid character selected: ' + value); - return value; - } - - return selectedCharacter = value; + return currentSongMetadata.playData.characters.player = value; } var currentSongCharacterOpponent(get, set):String; function get_currentSongCharacterOpponent():String { - // Validate selected character before returning it. - if (!currentSongPlayableCharacters.contains(selectedCharacter)) - { - trace('Invalid character selected: ' + selectedCharacter); - selectedCharacter = currentSongPlayableCharacters[0]; - } - - var playableCharData:SongPlayableChar = currentSongMetadata.playData.playableChars.get(selectedCharacter); - return playableCharData.opponent; + return currentSongMetadata.playData.characters.opponent; } function set_currentSongCharacterOpponent(value:String):String { - var playableCharData:SongPlayableChar = currentSongMetadata.playData.playableChars.get(selectedCharacter); - return playableCharData.opponent = value; + return currentSongMetadata.playData.characters.opponent = value; } /** @@ -1249,6 +1230,9 @@ class ChartEditorState extends HaxeUIState // Get rid of any music from the previous state. FlxG.sound.music.stop(); + // Play the welcome music. + setupWelcomeMusic(); + buildDefaultSongData(); buildBackground(); @@ -1273,6 +1257,26 @@ class ChartEditorState extends HaxeUIState ChartEditorDialogHandler.openWelcomeDialog(this, false); } + function setupWelcomeMusic() + { + this.welcomeMusic.loadEmbedded(Paths.music('chartEditorLoop/chartEditorLoop')); + this.welcomeMusic.looped = true; + // this.welcomeMusic.play(); + // fadeInWelcomeMusic(); + } + + public function fadeInWelcomeMusic():Void + { + this.welcomeMusic.play(); + this.welcomeMusic.fadeIn(4, 0, 1.0); + } + + public function stopWelcomeMusic():Void + { + // this.welcomeMusic.fadeOut(4, 0); + this.welcomeMusic.pause(); + } + function buildDefaultSongData():Void { selectedVariation = Constants.DEFAULT_VARIATION; @@ -1602,7 +1606,7 @@ class ChartEditorState extends HaxeUIState addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true)); addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseWizard(this, true)); - addUIClickListener('menubarItemSaveChartAs', _ -> exportAllSongData()); + addUIClickListener('menubarItemSaveChartAs', _ -> ChartEditorImportExportHandler.exportAllSongData(this)); addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true)); addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true)); @@ -1738,18 +1742,14 @@ class ChartEditorState extends HaxeUIState }); } - addUIChangeListener('menubarItemToggleToolboxTools', - event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT, event.value)); - addUIChangeListener('menubarItemToggleToolboxNotes', - event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value)); - addUIChangeListener('menubarItemToggleToolboxEvents', - event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value)); addUIChangeListener('menubarItemToggleToolboxDifficulty', event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value)); addUIChangeListener('menubarItemToggleToolboxMetadata', event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value)); - addUIChangeListener('menubarItemToggleToolboxCharacters', - event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT, event.value)); + addUIChangeListener('menubarItemToggleToolboxNotes', + event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value)); + addUIChangeListener('menubarItemToggleToolboxEvents', + event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value)); addUIChangeListener('menubarItemToggleToolboxPlayerPreview', event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value)); addUIChangeListener('menubarItemToggleToolboxOpponentPreview', @@ -1795,7 +1795,7 @@ class ChartEditorState extends HaxeUIState // Auto-save to local storage. #else // Auto-save to temp file. - exportAllSongData(true, true); + ChartEditorImportExportHandler.exportAllSongData(this, true, true); #end } @@ -1806,7 +1806,7 @@ class ChartEditorState extends HaxeUIState if (saveDataDirty) { - exportAllSongData(true); + ChartEditorImportExportHandler.exportAllSongData(this, true); } } @@ -2407,13 +2407,20 @@ class ChartEditorState extends HaxeUIState var dragLengthMs:Float = dragLengthSteps * Conductor.stepLengthMs; var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE; - gridGhostHoldNote.visible = true; - gridGhostHoldNote.noteData = gridGhostNote.noteData; - gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection(); + if (dragLengthSteps > 0) + { + gridGhostHoldNote.visible = true; + gridGhostHoldNote.noteData = gridGhostNote.noteData; + gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection(); - gridGhostHoldNote.setHeightDirectly(dragLengthPixels); + gridGhostHoldNote.setHeightDirectly(dragLengthPixels); - gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes); + gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes); + } + else + { + gridGhostHoldNote.visible = false; + } if (FlxG.mouse.justReleased) { @@ -3016,6 +3023,12 @@ class ChartEditorState extends HaxeUIState ChartEditorDialogHandler.openBrowseWizard(this, true); } + // CTRL + SHIFT + S = Save As + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.S) + { + ChartEditorImportExportHandler.exportAllSongData(this, false); + } + // CTRL + Q = Quit to Menu if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q) { @@ -3167,7 +3180,7 @@ class ChartEditorState extends HaxeUIState selectedDifficulty = prevDifficulty; refreshDifficultyTreeSelection(); - refreshSongMetadataToolbox(); + refreshMetadataToolbox(); } else { @@ -3176,7 +3189,7 @@ class ChartEditorState extends HaxeUIState selectedDifficulty = prevDifficulty; refreshDifficultyTreeSelection(); - refreshSongMetadataToolbox(); + refreshMetadataToolbox(); } } else @@ -3195,7 +3208,7 @@ class ChartEditorState extends HaxeUIState selectedDifficulty = nextDifficulty; refreshDifficultyTreeSelection(); - refreshSongMetadataToolbox(); + refreshMetadataToolbox(); } else { @@ -3204,7 +3217,7 @@ class ChartEditorState extends HaxeUIState selectedDifficulty = nextDifficulty; refreshDifficultyTreeSelection(); - refreshSongMetadataToolbox(); + refreshMetadataToolbox(); } } @@ -3296,6 +3309,28 @@ class ChartEditorState extends HaxeUIState } } + public function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0) + { + var variationMetadata:Null<SongMetadata> = songMetadata.get(variation); + if (variationMetadata == null) return; + + variationMetadata.playData.difficulties.push(difficulty); + + var resultChartData = songChartData.get(variation); + if (resultChartData == null) + { + resultChartData = new SongChartData([difficulty => scrollSpeed], [], [difficulty => []]); + songChartData.set(variation, resultChartData); + } + else + { + resultChartData.scrollSpeed.set(difficulty, scrollSpeed); + resultChartData.notes.set(difficulty, []); + } + + difficultySelectDirty = true; // Force the Difficulty toolbox to update. + } + function refreshDifficultyTreeSelection(?treeView:TreeView):Void { if (treeView == null) @@ -3469,7 +3504,7 @@ class ChartEditorState extends HaxeUIState selectedVariation = variation; selectedDifficulty = difficulty; // refreshDifficultyTreeSelection(treeView); - refreshSongMetadataToolbox(); + refreshMetadataToolbox(); } // case 'song': // case 'variation': @@ -3478,14 +3513,14 @@ class ChartEditorState extends HaxeUIState trace('Selected wrong node type, resetting selection.'); var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView); if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode; - refreshSongMetadataToolbox(); + refreshMetadataToolbox(); } } /** * When the difficulty changes, update the song metadata toolbox to reflect the new data. */ - function refreshSongMetadataToolbox():Void + function refreshMetadataToolbox():Void { var toolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); if (toolbox == null) return; @@ -3499,8 +3534,8 @@ class ChartEditorState extends HaxeUIState var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown); if (inputStage != null) inputStage.value = currentSongMetadata.playData.stage; - var inputNoteSkin:Null<DropDown> = toolbox.findComponent('inputNoteSkin', DropDown); - if (inputNoteSkin != null) inputNoteSkin.value = currentSongMetadata.playData.noteSkin; + var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown); + if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteSkin; var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper); if (inputBPM != null) inputBPM.value = currentSongMetadata.timeChanges[0].bpm; @@ -3515,16 +3550,54 @@ class ChartEditorState extends HaxeUIState if (frameVariation != null) frameVariation.text = 'Variation: ${selectedVariation.toTitleCase()}'; var frameDifficulty:Null<Frame> = toolbox.findComponent('frameDifficulty', Frame); if (frameDifficulty != null) frameDifficulty.text = 'Difficulty: ${selectedDifficulty.toTitleCase()}'; - } - function addDifficulty(variation:String):Void {} + var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown); + var stageId:String = currentSongMetadata.playData.stage; + var stageData:Null<StageData> = StageDataParser.parseStageData(stageId); + if (stageData != null) + { + inputStage.value = {id: stageId, text: stageData.name}; + } + else + { + inputStage.value = {id: "mainStage", text: "Main Stage"}; + } - function addVariation(variationId:String):Void - { - // Create a new variation with the specified ID. - songMetadata.set(variationId, currentSongMetadata.clone(variationId)); - // Switch to the new variation. - selectedVariation = variationId; + var inputCharacterPlayer:Null<DropDown> = toolbox.findComponent('inputCharacterPlayer', DropDown); + var charIdPlayer:String = currentSongMetadata.playData.characters.player; + var charDataPlayer:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdPlayer); + if (charDataPlayer != null) + { + inputCharacterPlayer.value = {id: charIdPlayer, text: charDataPlayer.name}; + } + else + { + inputCharacterPlayer.value = {id: "bf", text: "Boyfriend"}; + } + + var inputCharacterOpponent:Null<DropDown> = toolbox.findComponent('inputCharacterOpponent', DropDown); + var charIdOpponent:String = currentSongMetadata.playData.characters.opponent; + var charDataOpponent:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdOpponent); + if (charDataOpponent != null) + { + inputCharacterOpponent.value = {id: charIdOpponent, text: charDataOpponent.name}; + } + else + { + inputCharacterOpponent.value = {id: "dad", text: "Dad"}; + } + + var inputCharacterGirlfriend:Null<DropDown> = toolbox.findComponent('inputCharacterGirlfriend', DropDown); + var charIdGirlfriend:String = currentSongMetadata.playData.characters.girlfriend; + var charDataGirlfriend:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdGirlfriend); + if (charDataGirlfriend != null) + { + inputCharacterGirlfriend.value = {id: charIdGirlfriend, text: charDataGirlfriend.name}; + } + else + { + inputCharacterGirlfriend.value = {id: "none", text: "None"}; + } } /** @@ -3710,9 +3783,9 @@ class ChartEditorState extends HaxeUIState switch (noteData.getStrumlineIndex()) { case 0: // Player - if (hitsoundsEnabledPlayer) playSound(Paths.sound('funnyNoise/funnyNoise-09')); + if (hitsoundsEnabledPlayer) ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-09')); case 1: // Opponent - if (hitsoundsEnabledOpponent) playSound(Paths.sound('funnyNoise/funnyNoise-010')); + if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-010')); } } } @@ -3913,77 +3986,6 @@ class ChartEditorState extends HaxeUIState Conductor.update(targetPos); } - /** - * Loads an instrumental from an absolute file path, replacing the current instrumental. - * - * @param path The absolute path to the audio file. - * - * @return Success or failure. - */ - public function loadInstrumentalFromPath(path:Path):Bool - { - #if sys - // Validate file extension. - if (path.ext != null && !SUPPORTED_MUSIC_FORMATS.contains(path.ext)) - { - return false; - } - - var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString()); - return loadInstrumentalFromBytes(fileBytes, '${path.file}.${path.ext}'); - #else - trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); - return false; - #end - } - - /** - * Loads an instrumental from audio byte data, replacing the current instrumental. - * @param bytes The audio byte data. - * @param fileName The name of the file, if available. Used for notifications. - * @return Success or failure. - */ - public function loadInstrumentalFromBytes(bytes:haxe.io.Bytes, fileName:String = null):Bool - { - if (bytes == null) - { - return false; - } - - var openflSound:openfl.media.Sound = 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(); - - audioInstTrackData = bytes; - - postLoadInstrumental(); - - return true; - } - - /** - * Loads an instrumental from an OpenFL asset, replacing the current instrumental. - * @param path The path to the asset. Use `Paths` to build this. - * @return Success or failure. - */ - public function loadInstrumentalFromAsset(path:String):Bool - { - var instTrack:FlxSound = FlxG.sound.load(path, 1.0, false); - if (instTrack != null) - { - audioInstTrack = instTrack; - - audioInstTrackData = Assets.getBytes(path); - - postLoadInstrumental(); - return true; - } - - return false; - } - public function postLoadInstrumental():Void { if (audioInstTrack != null) @@ -4014,23 +4016,6 @@ class ChartEditorState extends HaxeUIState moveSongToScrollPosition(); } - /** - * Loads a vocal track from an absolute file path. - * @param path The absolute path to the audio file. - * @param charKey The character to load the vocal track for. - * @return Success or failure. - */ - public function loadVocalsFromPath(path:Path, charKey:String = 'default'):Bool - { - #if sys - var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString()); - return loadVocalsFromBytes(fileBytes, charKey); - #else - trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); - return false; - #end - } - /** * Clear the voices group. */ @@ -4039,141 +4024,6 @@ class ChartEditorState extends HaxeUIState if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear(); } - /** - * Load a vocal track for a given song and character and add it to the voices group. - * - * @param path ID of the asset. - * @param charKey Character to load the vocal track for. - * @return Success or failure. - */ - public function loadVocalsFromAsset(path:String, charType:CharacterType = OTHER):Bool - { - var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false); - if (vocalTrack != null) - { - switch (charType) - { - case CharacterType.BF: - if (audioVocalTrackGroup != null) audioVocalTrackGroup.addPlayerVoice(vocalTrack); - audioVocalTrackData.set(currentSongCharacterPlayer, Assets.getBytes(path)); - case CharacterType.DAD: - if (audioVocalTrackGroup != null) audioVocalTrackGroup.addOpponentVoice(vocalTrack); - audioVocalTrackData.set(currentSongCharacterOpponent, Assets.getBytes(path)); - default: - if (audioVocalTrackGroup != null) audioVocalTrackGroup.add(vocalTrack); - audioVocalTrackData.set('default', Assets.getBytes(path)); - } - - return true; - } - return false; - } - - /** - * Loads a vocal track from audio byte data. - */ - public function loadVocalsFromBytes(bytes:haxe.io.Bytes, charKey:String = ''):Bool - { - var openflSound:openfl.media.Sound = new openfl.media.Sound(); - openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length); - var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.add(vocalTrack); - audioVocalTrackData.set(charKey, bytes); - return true; - } - - /** - * Fetch's a song's existing chart and audio and loads it, replacing the current song. - */ - public function loadSongAsTemplate(songId:String):Void - { - var song:Null<Song> = SongRegistry.instance.fetchEntry(songId); - - if (song == null) return; - - // Load the song metadata. - var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata(); - var songMetadata:Map<String, SongMetadata> = []; - var songChartData:Map<String, SongChartData> = []; - - for (metadata in rawSongMetadata) - { - if (metadata == null) continue; - var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation; - - // Clone to prevent modifying the original. - var metadataClone:SongMetadata = metadata.clone(variation); - if (metadataClone != null) songMetadata.set(variation, metadataClone); - - songChartData.set(variation, SongRegistry.instance.parseEntryChartData(songId, metadata.variation)); - } - - loadSong(songMetadata, songChartData); - - sortChartData(); - - clearVocals(); - - loadInstrumentalFromAsset(Paths.inst(songId)); - - var diff:Null<SongDifficulty> = song.getDifficulty(selectedDifficulty); - var voiceList:Array<String> = diff != null ? diff.buildVoiceList(currentSongCharacterPlayer) : []; - if (voiceList.length == 2) - { - loadVocalsFromAsset(voiceList[0], BF); - loadVocalsFromAsset(voiceList[1], DAD); - } - else - { - for (voicePath in voiceList) - { - loadVocalsFromAsset(voicePath); - } - } - - #if !mac - NotificationManager.instance.addNotification( - { - title: 'Success', - body: 'Loaded song (${rawSongMetadata[0].songName})', - type: NotificationType.Success, - expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME - }); - #end - } - - /** - * Loads song metadata and chart data into the editor. - * @param newSongMetadata The song metadata to load. - * @param newSongChartData The song chart data to load. - */ - public function loadSong(newSongMetadata:Map<String, SongMetadata>, newSongChartData:Map<String, SongChartData>):Void - { - this.songMetadata = newSongMetadata; - this.songChartData = newSongChartData; - - Conductor.forceBPM(null); // Disable the forced BPM. - Conductor.mapTimeChanges(currentSongMetadata.timeChanges); - - notePreviewDirty = true; - notePreviewViewportBoundsDirty = true; - difficultySelectDirty = true; - opponentPreviewDirty = true; - playerPreviewDirty = true; - - // Remove instrumental and vocal tracks, they will be loaded next. - if (audioInstTrack != null) - { - audioInstTrack.stop(); - audioInstTrack = null; - } - if (audioVocalTrackGroup != null) - { - audioVocalTrackGroup.stop(); - audioVocalTrackGroup.clear(); - } - } - /** * 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. @@ -4291,7 +4141,7 @@ class ChartEditorState extends HaxeUIState function playMetronomeTick(high:Bool = false):Void { - playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}')); + ChartEditorAudioHandler.playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}')); } function isNoteSelected(note:Null<SongNoteData>):Bool @@ -4304,27 +4154,6 @@ class ChartEditorState extends HaxeUIState return event != null && currentEventSelection.indexOf(event) != -1; } - /** - * Play a sound effect. - * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance. - */ - function playSound(path:String):Void - { - var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound(); - - var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path); - if (asset == null) - { - trace('WARN: Failed to play sound $path, asset not found.'); - return; - } - - snd.loadEmbedded(asset); - snd.autoDestroy = true; - FlxG.sound.list.add(snd); - snd.play(); - } - override function destroy():Void { super.destroy(); @@ -4345,78 +4174,6 @@ class ChartEditorState extends HaxeUIState { NotificationManager.instance.clearNotifications(); } - - /** - * @param force Whether to force the export without prompting the user for a file location. - * @param tmp If true, save to the temporary directory instead of the local `backup` directory. - */ - public function exportAllSongData(force:Bool = false, tmp:Bool = false):Void - { - var zipEntries:Array<haxe.zip.Entry> = []; - - for (variation in availableVariations) - { - var variationId:String = variation; - if (variation == '' || variation == 'default' || variation == 'normal') - { - variationId = ''; - } - - if (variationId == '') - { - var variationMetadata:Null<SongMetadata> = songMetadata.get(variation); - if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata.json', SerializerUtil.toJSON(variationMetadata))); - var variationChart:Null<SongChartData> = songChartData.get(variation); - if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart.json', SerializerUtil.toJSON(variationChart))); - } - else - { - var variationMetadata:Null<SongMetadata> = songMetadata.get(variation); - if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata-$variationId.json', - SerializerUtil.toJSON(variationMetadata))); - var variationChart:Null<SongChartData> = songChartData.get(variation); - if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart-$variationId.json', SerializerUtil.toJSON(variationChart))); - } - } - - if (audioInstTrackData != null) zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', audioInstTrackData)); - for (charId in audioVocalTrackData.keys()) - { - var entryData = audioVocalTrackData.get(charId); - if (entryData == null) continue; - zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-$charId.ogg', entryData)); - } - - trace('Exporting ${zipEntries.length} files to ZIP...'); - - if (force) - { - var targetPath:String = if (tmp) - { - Path.join([FileUtil.getTempDir(), 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']); - } - else - { - 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<String>->Void = function(paths:Array<String>) { - trace('Successfully exported files.'); - }; - - var onCancel:Void->Void = function() { - trace('Export cancelled.'); - }; - - FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '$currentSongId-chart.zip'); - } } enum LiveInputStyle diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx index db090542d..6f89b6b63 100644 --- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx @@ -1,5 +1,10 @@ package funkin.ui.debug.charting; +import funkin.ui.haxeui.components.FunkinDropDown; +import funkin.play.stage.StageData.StageDataParser; +import funkin.play.stage.StageData; +import funkin.play.character.CharacterData; +import funkin.play.character.CharacterData.CharacterDataParser; import haxe.ui.components.HorizontalSlider; import haxe.ui.containers.TreeView; import haxe.ui.containers.TreeViewNode; @@ -9,6 +14,7 @@ import funkin.data.event.SongEventData; import funkin.data.song.SongData.SongTimeChange; import funkin.play.song.SongSerializer; import funkin.ui.haxeui.components.CharacterPlayer; +import funkin.util.FileUtil; import haxe.ui.components.Button; import haxe.ui.components.CheckBox; import haxe.ui.components.DropDown; @@ -78,8 +84,6 @@ class ChartEditorToolboxHandler onShowToolboxDifficulty(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: onShowToolboxMetadata(state, toolbox); - case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT: - onShowToolboxCharacters(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: onShowToolboxPlayerPreview(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: @@ -117,8 +121,6 @@ class ChartEditorToolboxHandler onHideToolboxDifficulty(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: onHideToolboxMetadata(state, toolbox); - case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT: - onHideToolboxCharacters(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: onHideToolboxPlayerPreview(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: @@ -167,8 +169,6 @@ class ChartEditorToolboxHandler 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: @@ -445,14 +445,20 @@ class ChartEditorToolboxHandler state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false); } + var difficultyToolboxAddVariation:Null<Button> = toolbox.findComponent('difficultyToolboxAddVariation', Button); + if (difficultyToolboxAddVariation == null) + throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddVariation component.'; + var difficultyToolboxAddDifficulty:Null<Button> = toolbox.findComponent('difficultyToolboxAddDifficulty', Button); + if (difficultyToolboxAddDifficulty == null) + throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddDifficulty component.'; var difficultyToolboxSaveMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxSaveMetadata', Button); if (difficultyToolboxSaveMetadata == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveMetadata component.'; var difficultyToolboxSaveChart:Null<Button> = toolbox.findComponent('difficultyToolboxSaveChart', Button); if (difficultyToolboxSaveChart == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveChart component.'; - var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button); - if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.'; + // var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button); + // if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.'; var difficultyToolboxLoadMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxLoadMetadata', Button); if (difficultyToolboxLoadMetadata == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadMetadata component.'; @@ -460,26 +466,32 @@ class ChartEditorToolboxHandler if (difficultyToolboxLoadChart == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadChart component.'; - difficultyToolboxSaveMetadata.onClick = function(event:UIEvent) { - SongSerializer.exportSongMetadata(state.currentSongMetadata, state.currentSongId); + difficultyToolboxAddVariation.onClick = function(_:UIEvent) { + ChartEditorDialogHandler.openAddVariationDialog(state, true); }; - difficultyToolboxSaveChart.onClick = function(event:UIEvent) { - SongSerializer.exportSongChartData(state.currentSongChartData, state.currentSongId); + difficultyToolboxAddDifficulty.onClick = function(_:UIEvent) { + ChartEditorDialogHandler.openAddDifficultyDialog(state, true); }; - difficultyToolboxSaveAll.onClick = function(event:UIEvent) { - state.exportAllSongData(); + difficultyToolboxSaveMetadata.onClick = function(_:UIEvent) { + var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : ''; + FileUtil.writeFileReference('${state.currentSongId}$vari-metadata.json', state.currentSongMetadata.serialize()); }; - difficultyToolboxLoadMetadata.onClick = function(event:UIEvent) { + difficultyToolboxSaveChart.onClick = function(_:UIEvent) { + var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : ''; + FileUtil.writeFileReference('${state.currentSongId}$vari-chart.json', state.currentSongChartData.serialize()); + }; + + difficultyToolboxLoadMetadata.onClick = function(_:UIEvent) { // Replace metadata for current variation. SongSerializer.importSongMetadataAsync(function(songMetadata) { state.currentSongMetadata = songMetadata; }); }; - difficultyToolboxLoadChart.onClick = function(event:UIEvent) { + difficultyToolboxLoadChart.onClick = function(_:UIEvent) { // Replace chart data for current variation. SongSerializer.importSongChartDataAsync(function(songChartData) { state.currentSongChartData = songChartData; @@ -554,7 +566,7 @@ class ChartEditorToolboxHandler }; inputSongArtist.value = state.currentSongMetadata.artist; - var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown); + var inputStage:Null<FunkinDropDown> = toolbox.findComponent('inputStage', FunkinDropDown); if (inputStage == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputStage component.'; inputStage.onChange = function(event:UIEvent) { var valid:Bool = event.data != null && event.data.id != null; @@ -564,15 +576,48 @@ class ChartEditorToolboxHandler state.currentSongMetadata.playData.stage = event.data.id; } }; - inputStage.value = state.currentSongMetadata.playData.stage; + var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, state.currentSongMetadata.playData.stage); + inputStage.value = startingValueStage; - var inputNoteSkin:Null<DropDown> = toolbox.findComponent('inputNoteSkin', DropDown); - if (inputNoteSkin == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteSkin component.'; - inputNoteSkin.onChange = function(event:UIEvent) { - if ((event?.data?.id ?? null) == null) return; - state.currentSongNoteSkin = event.data.id; + var inputNoteStyle:Null<FunkinDropDown> = toolbox.findComponent('inputNoteStyle', FunkinDropDown); + if (inputNoteStyle == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteStyle component.'; + inputNoteStyle.onChange = function(event:UIEvent) { + if (event.data?.id == null) return; + state.currentSongNoteStyle = event.data.id; }; - inputNoteSkin.value = state.currentSongNoteSkin; + inputNoteStyle.value = state.currentSongNoteStyle; + + // By using this flag, we prevent the dropdown value from changing while it is being populated. + + var inputCharacterPlayer:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterPlayer', FunkinDropDown); + if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.'; + inputCharacterPlayer.onChange = function(event:UIEvent) { + if (event.data?.id == null) return; + state.currentSongMetadata.playData.characters.player = event.data.id; + }; + var startingValuePlayer = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterPlayer, CharacterType.BF, + state.currentSongMetadata.playData.characters.player); + inputCharacterPlayer.value = startingValuePlayer; + + var inputCharacterOpponent:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterOpponent', FunkinDropDown); + if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.'; + inputCharacterOpponent.onChange = function(event:UIEvent) { + if (event.data?.id == null) return; + state.currentSongMetadata.playData.characters.opponent = event.data.id; + }; + var startingValueOpponent = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterOpponent, CharacterType.DAD, + state.currentSongMetadata.playData.characters.opponent); + inputCharacterOpponent.value = startingValueOpponent; + + var inputCharacterGirlfriend:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterGirlfriend', FunkinDropDown); + if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.'; + inputCharacterGirlfriend.onChange = function(event:UIEvent) { + if (event.data?.id == null) return; + state.currentSongMetadata.playData.characters.girlfriend = event.data.id == "none" ? "" : event.data.id; + }; + var startingValueGirlfriend = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterGirlfriend, CharacterType.GF, + state.currentSongMetadata.playData.characters.girlfriend); + inputCharacterGirlfriend.value = startingValueGirlfriend; var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper); if (inputBPM == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputBPM component.'; @@ -630,32 +675,11 @@ class ChartEditorToolboxHandler static function onShowToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void { - state.refreshSongMetadataToolbox(); + state.refreshMetadataToolbox(); } static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function buildToolboxCharactersLayout(state:ChartEditorState):Null<CollapsibleDialog> - { - var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 175; - toolbox.y = 300; - - toolbox.onDialogClosed = function(event:DialogEvent) { - state.setUICheckboxSelected('menubarItemToggleToolboxCharacters', false); - } - - return toolbox; - } - - static function onShowToolboxCharacters(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - - static function onHideToolboxCharacters(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Null<CollapsibleDialog> { var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT); diff --git a/source/funkin/ui/haxeui/components/FunkinDropdown.hx b/source/funkin/ui/haxeui/components/FunkinDropDown.hx similarity index 100% rename from source/funkin/ui/haxeui/components/FunkinDropdown.hx rename to source/funkin/ui/haxeui/components/FunkinDropDown.hx diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx index b454ca429..efabf10c3 100644 --- a/source/funkin/util/Constants.hx +++ b/source/funkin/util/Constants.hx @@ -2,6 +2,7 @@ package funkin.util; import flixel.util.FlxColor; import lime.app.Application; +import funkin.data.song.SongData.SongTimeFormat; class Constants { @@ -22,6 +23,16 @@ class Constants */ public static var VERSION(get, never):String; + /** + * The generatedBy string embedded in the chart files made by this application. + */ + public static var GENERATED_BY(get, never):String; + + static function get_GENERATED_BY():String + { + return '${Constants.TITLE} - ${Constants.VERSION}'; + } + /** * A suffix to add to the game version. * Add a suffix to prototype builds and remove it for releases. @@ -140,7 +151,32 @@ class Constants /** * The default BPM for charts, so things don't break if none is specified. */ - public static final DEFAULT_BPM:Int = 100; + public static final DEFAULT_BPM:Float = 100.0; + + /** + * The default name for songs. + */ + public static final DEFAULT_SONGNAME:String = "Unknown"; + + /** + * The default artist for songs. + */ + public static final DEFAULT_ARTIST:String = "Unknown"; + + /** + * The default note style for songs. + */ + public static final DEFAULT_NOTE_STYLE:String = "funkin"; + + /** + * The default timing format for songs. + */ + public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS; + + /** + * The default scroll speed for songs. + */ + public static final DEFAULT_SCROLLSPEED:Float = 1.0; /** * Default numerator for the time signature. @@ -288,16 +324,60 @@ class Constants public static final HEALTH_MINE_PENALTY:Float = 15.0 / 100.0 * HEALTH_MAX; // 15.0% /** - * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window. - * This is the thing people have been begging for forever lolol. + * SCORE VALUES */ - public static final GHOST_TAPPING:Bool = false; + // ============================== + + /** + * The amount of score the player gains for every send they hold a hold note. + * A fraction of this value is granted every frame. + */ + public static final SCORE_HOLD_BONUS_PER_SECOND:Float = 250.0; + + /** + * FILE EXTENSIONS + */ + // ============================== + + /** + * The file extension used when exporting chart files. + * + * - "I made a new file format" + * - "Actually new or just a renamed ZIP?" + */ + public static final EXT_CHART = "fnfc"; + + /** + * The file extension used when loading audio files. + */ + public static final EXT_SOUND = #if web "mp3" #else "ogg" #end; + + /** + * The file extension used when loading video files. + */ + public static final EXT_VIDEO = "mp4"; + + /** + * The file extension used when loading image files. + */ + public static final EXT_IMAGE = "png"; + + /** + * The file extension used when loading data files. + */ + public static final EXT_DATA = "json"; /** * OTHER */ // ============================== + /** + * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window. + * This is the thing people have been begging for forever lolol. + */ + public static final GHOST_TAPPING:Bool = false; + /** * The separator between an asset library and the asset path. */ diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx index 3a6f4e330..bae3126fb 100644 --- a/source/funkin/util/FileUtil.hx +++ b/source/funkin/util/FileUtil.hx @@ -5,10 +5,9 @@ import lime.utils.Bytes; import lime.ui.FileDialog; import openfl.net.FileFilter; import haxe.io.Path; -#if html5 import openfl.net.FileReference; import openfl.events.Event; -#end +import openfl.events.IOErrorEvent; /** * Utilities for reading and writing files on various platforms. @@ -260,8 +259,7 @@ class FileUtil /** * Takes an array of file entries and prompts the user to save them as a ZIP file. */ - public static function saveFilesAsZIP(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String, - force:Bool = false):Bool + public static function saveFilesAsZIP(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String, force:Bool = false):Bool { // Create a ZIP file. var zipBytes:Bytes = createZIPFromEntries(resources); @@ -309,6 +307,7 @@ class FileUtil #if sys return sys.io.File.getContent(path); #else + trace('ERROR: readStringFromPath not implemented for this platform'); return null; #end } @@ -329,6 +328,48 @@ class FileUtil #end } + /** + * Browse for a file to read and execute a callback once we have a file reference. + * Works great on HTML5 or desktop. + * + * @param callback The function to call when the file is loaded. + */ + public static function browseFileReference(callback:FileReference->Void) + { + var file = new FileReference(); + + file.addEventListener(Event.SELECT, function(e) { + var selectedFileRef:FileReference = e.target; + trace('Selected file: ' + selectedFileRef.name); + selectedFileRef.addEventListener(Event.COMPLETE, function(e) { + var loadedFileRef:FileReference = e.target; + trace('Loaded file: ' + loadedFileRef.name); + callback(loadedFileRef); + }); + selectedFileRef.load(); + }); + + file.browse(); + } + + /** + * Prompts the user to save a file to their computer. + */ + public static function writeFileReference(path:String, data:String) + { + var file = new FileReference(); + file.addEventListener(Event.COMPLETE, function(e:Event) { + trace('Successfully wrote file.'); + }); + file.addEventListener(Event.CANCEL, function(e:Event) { + trace('Cancelled writing file.'); + }); + file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) { + trace('IO error writing file.'); + }); + file.save(data, path); + } + /** * Read JSON file contents directly from a given path. * Only works on desktop. diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx index 26563efce..0af0fc9ea 100644 --- a/source/funkin/util/SerializerUtil.hx +++ b/source/funkin/util/SerializerUtil.hx @@ -13,6 +13,7 @@ typedef ScoreInput = /** * A class of functions dedicated to serializing and deserializing data. + * TODO: Rewrite/refactor this to use json2object. */ class SerializerUtil {