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 & Constructible; + /** + * 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; + /** * 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 & Constructible; - function printErrors(errors:Array, id:String = ''):Void + function printErrors(errors:Array, 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, 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, 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 + /** + * Parser which outputs a `Either`. + * Used by the FNF legacy JSON importer. + */ + public static function eitherLegacyScrollSpeeds(json:Json, name:String):Either { - 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):Array> { - return [for (json in jsons) jsonToDynamic(json)]; + return [for (json in jsons) Tools.getValue(json)]; + } + + static function legacyNoteSectionArray(json:Json, name:String):Array + { + 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 + { + 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 = values[0] == null ? null : Tools.getValue(values[0]); + // var data:Null = values[1] == null ? null : Tools.getValue(values[1]); + // var length:Null = values[2] == null ? null : Tools.getValue(values[2]); + // var alt:Null = 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; @@ -85,7 +84,7 @@ typedef UnnamedAnimationData = */ @:default(false) @:optional - var looped:Null; + 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 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 + { + var parser = new json2object.JsonParser(); + 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 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 + { + var parser = new json2object.JsonParser(); + 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(); + // 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; /** @@ -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; // @: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; + + /** + * The difficulties contained in this song's chart file. + */ public var difficulties:Array; /** - * 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; + 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 = []; + + 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, events:Array, notes:Map>) { this.version = SongRegistry.SONG_CHART_DATA_VERSION; @@ -346,14 +419,21 @@ class SongChartData return value; } - public function getEvents():Array + /** + * Convert this SongChartData into a JSON string. + */ + public function serialize(pretty:Bool = true):String { - return this.events; + var writer = new json2object.JsonWriter(); + return writer.write(this, pretty ? ' ' : null); } - public function setEvents(value:Array):Array + /** + * 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 { /** @@ -15,14 +18,18 @@ class SongRegistry extends BaseRegistry * 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 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 return parseEntryMetadata(id); } - public function parseEntryMetadata(id:String, variation:String = ""):Null + /** + * Parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String = 'raw'):Null { - // JsonParser does not take type parameters, - // otherwise this function would be in BaseRegistry. + return parseEntryMetadataRaw(contents); + } + + public function parseEntryMetadata(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser(); + 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 + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + 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 + { + 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 + { + // 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 + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); switch (loadEntryMetadataFile(id)) { case {fileName: fileName, contents: contents}: @@ -114,6 +204,39 @@ class SongRegistry extends BaseRegistry 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 + { + var parser = new json2object.JsonParser(); + 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 + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + 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 return parser.value; } - public function parseEntryMetadataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null + public function parseMusicDataRaw(contents:String, ?fileName:String = 'raw'):Null { - // 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(); + 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 + { + 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 + public function parseMusicDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null { - // JsonParser does not take type parameters, - // otherwise this function would be in BaseRegistry. - - var parser = new json2object.JsonParser(); - 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 + public function parseEntryChartData(id:String, ?variation:String):Null { - // 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(); - switch (loadEntryChartFile(id)) + switch (loadEntryChartFile(id, variation)) { case {fileName: fileName, contents: contents}: parser.fromJson(contents, fileName); @@ -177,11 +306,28 @@ class SongRegistry extends BaseRegistry 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 + public function parseEntryChartDataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + 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 + { + 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 } 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 + { + // 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 return ScriptedSong.listScriptClasses(); } - function loadEntryMetadataFile(id:String, variation:String = ''):Null + function loadEntryMetadataFile(id:String, ?variation:String):Null { - 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 = openfl.Assets.getText(entryFilePath); if (rawJson == null) return null; @@ -213,9 +373,10 @@ class SongRegistry extends BaseRegistry return {fileName: entryFilePath, contents: rawJson}; } - function loadMusicDataFile(id:String, variation:String = ''):Null + function loadMusicDataFile(id:String, ?variation:String):Null { - 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 return {fileName: entryFilePath, contents: rawJson}; } - function loadEntryChartFile(id:String, variation:String = ''):Null + function loadEntryChartFile(id:String, ?variation:String):Null { - 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 return {fileName: entryFilePath, contents: rawJson}; } - public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null + public function fetchEntryMetadataVersion(id:String, ?variation:String):Null { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryStr:Null = loadEntryMetadataFile(id, variation)?.contents; var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } - public function fetchEntryChartVersion(id:String, variation:String = ''):Null + public function fetchEntryChartVersion(id:String, ?variation:String):Null { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryStr:Null = 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; + public var stageDefault:String; + public var bpm:Float; + + @:jcustomparse(funkin.data.DataParse.eitherLegacyNoteData) + public var notes:Either, 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; + + /** + * The normal difficulty. + */ + public var ?normal:Array; + + /** + * The hard difficulty. + */ + public var ?hard:Array; +}; + +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; + + 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 + * 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(); + 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 + { + var result:Array = []; + + 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 = 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 + { + var result:Array = []; + + 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):Array + { + var result:Array = []; + + 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 = input.playableChars.size() == 0 ? null : input.playableChars.keys().array()[0]; + var firstCharData:Null = 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; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + public var generatedBy:String; + + public var timeFormat:SongData.SongTimeFormat; + + public var timeChanges:Array; + + 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; + + // ========== + // UNMODIFIED VALUES + // ========== + public var songVariations:Array; + public var difficulties:Array; + + 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 = 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 = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT]; - var pressArray:Array = [ - controls.NOTE_LEFT_P, - controls.NOTE_DOWN_P, - controls.NOTE_UP_P, - controls.NOTE_RIGHT_P - ]; - var releaseArray:Array = [ - controls.NOTE_LEFT_R, - controls.NOTE_DOWN_R, - controls.NOTE_UP_R, - controls.NOTE_RIGHT_R - ]; - - // if (pressArray.contains(true)) - // { - // var lol:Array = cast pressArray; - // inputSpitter.push(Std.int(Conductor.songPosition) + ' ' + lol.join(' ')); - // } - - // HOLDS, check for sustain notes - if (holdArray.contains(true) && generatedMusic) - { - /* - activeNotes.forEachAlive(function(daNote:Note) { - if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData]) goodNoteHit(daNote); - }); - */ - } - - // PRESSES, check for note hits - if (pressArray.contains(true) && generatedMusic) - { - Haptic.vibrate(100, 100); - - if (currentStage != null && currentStage.getBoyfriend() != null) - { - currentStage.getBoyfriend().holdTimer = 0; - } - - var possibleNotes:Array = []; // notes that can be hit - var directionList:Array = []; // directions that can be hit - var dumbNotes:Array = []; // notes to kill later - - 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 chartData in charts) result.applyChartData(chartData, variation); @@ -144,10 +145,10 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry(); - if (metadata.playData.playableChars == null) continue; - for (charId in metadata.playData.playableChars.keys()) - { - var char:Null = 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 = 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 @@ -365,19 +359,20 @@ class SongDifficulty */ public var events:Array; - public var songName:String = SongValidator.DEFAULT_SONGNAME; - public var songArtist:String = SongValidator.DEFAULT_ARTIST; - public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT; - public var divisions:Null = 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 = null; + public var looped:Bool = false; public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY; public var timeChanges:Array = []; - public var stage:String = SongValidator.DEFAULT_STAGE; - public var chars:Map = 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 - { - if (id == null || id == '') return null; - return chars.get(id); - } - - public function getPlayableChars():Array - { - return chars.keys().array(); - } - public function getEvents():Array { return cast events; } - public inline function cacheInst(?currentPlayerId:String = null):Void + public function cacheInst(instrumental = ''):Void { - var currentPlayer:Null = 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 + public function buildVoiceList():Array { - 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 = buildVoiceList(charId); + var voiceList:Array = 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 = 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):Array - { - var songNotes:Array = []; - - 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):Array - { - var songEvents:Array = []; - - var lastSectionWasMustHit:Null = 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 = 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, songId:String = 'unknown'):Array - { - 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; - var song:String; // Song name -}; - -typedef LegacyScrollSpeeds = -{ - var easy:Float; - var normal:Float; - var hard:Float; -}; - -typedef LegacyNoteData = -{ - /** - * The easy difficulty. - */ - var ?easy:Array; - - /** - * The normal difficulty. - */ - var ?normal:Array; - - /** - * The hard difficulty. - */ - var ?hard:Array; -}; - -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; - - var typeOfSection:Int; - var lengthInSteps:Int; -} - -/** - * Notes in the old format are stored as an Array - */ -abstract LegacyNote(Array) -{ - 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = state.currentSongMetadata.timeChanges; + var timeChanges:Array = 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 = dialog.findComponent('dialogCharGrid', PropertyGrid); - if (dialogCharGrid == null) throw 'Could not locate dialogCharGrid PropertyGrid in Song Metadata dialog'; - var dialogCharAdd:Null