diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 000000000..26958a467 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,6 @@ +# Troubleshooting Common Issues + +- Weird macro error with a very tall call stack: Restart Visual Studio Code +- `Get Thread Context Failed`: Turn off other expensive applications while building +- `Type not found: T1`: This is thrown by `json2object`, make sure the data type of `@:default` is correct. + - NOTE: `flixel.util.typeLimit.OneOfTwo` isn't supported. diff --git a/hmm.json b/hmm.json index e2670420a..26b32fbea 100644 --- a/hmm.json +++ b/hmm.json @@ -95,8 +95,10 @@ }, { "name": "json2object", - "type": "haxelib", - "version": "3.11.0" + "type": "git", + "dir": null, + "ref": "429986134031cbb1980f09d0d3d642b4b4cbcd6a", + "url": "https://github.com/elnabo/json2object" }, { "name": "lime", @@ -158,4 +160,4 @@ "version": "0.11.0" } ] -} +} \ No newline at end of file diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index b0ad6c221..5ffb52b22 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -4,7 +4,7 @@ import funkin.util.Constants; import flixel.util.FlxSignal; import flixel.math.FlxMath; import funkin.play.song.Song.SongDifficulty; -import funkin.play.song.SongData.SongTimeChange; +import funkin.data.song.SongData.SongTimeChange; /** * A core class which handles musical timing throughout the game, diff --git a/source/funkin/DialogueBox.hx b/source/funkin/DialogueBox.hx index 342fcba10..68d330dbe 100644 --- a/source/funkin/DialogueBox.hx +++ b/source/funkin/DialogueBox.hx @@ -37,7 +37,7 @@ class DialogueBox extends FlxSpriteGroup { super(); - switch (PlayState.instance.currentSong.songId.toLowerCase()) + switch (PlayState.instance.currentSong.id.toLowerCase()) { case 'senpai': FlxG.sound.playMusic(Paths.music('Lunchbox'), 0); @@ -78,7 +78,7 @@ class DialogueBox extends FlxSpriteGroup box = new FlxSprite(-20, 45); var hasDialog:Bool = false; - switch (PlayState.instance.currentSong.songId.toLowerCase()) + switch (PlayState.instance.currentSong.id.toLowerCase()) { case 'senpai': hasDialog = true; @@ -150,8 +150,8 @@ class DialogueBox extends FlxSpriteGroup override function update(elapsed:Float):Void { // HARD CODING CUZ IM STUPDI - if (PlayState.instance.currentSong.songId.toLowerCase() == 'roses') portraitLeft.visible = false; - if (PlayState.instance.currentSong.songId.toLowerCase() == 'thorns') + if (PlayState.instance.currentSong.id.toLowerCase() == 'roses') portraitLeft.visible = false; + if (PlayState.instance.currentSong.id.toLowerCase() == 'thorns') { portraitLeft.color = FlxColor.BLACK; swagDialogue.color = FlxColor.WHITE; @@ -187,8 +187,8 @@ class DialogueBox extends FlxSpriteGroup { isEnding = true; - if (PlayState.instance.currentSong.songId.toLowerCase() == 'senpai' - || PlayState.instance.currentSong.songId.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0); + if (PlayState.instance.currentSong.id.toLowerCase() == 'senpai' + || PlayState.instance.currentSong.id.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0); new FlxTimer().start(0.2, function(tmr:FlxTimer) { box.alpha -= 1 / 5; diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx index c31e8c77b..6cd353233 100644 --- a/source/funkin/FreeplayState.hx +++ b/source/funkin/FreeplayState.hx @@ -20,6 +20,7 @@ import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxColor; +import funkin.data.song.SongRegistry; import flixel.util.FlxSpriteUtil; import flixel.util.FlxTimer; import funkin.Controls.Control; @@ -30,7 +31,6 @@ import funkin.freeplayStuff.LetterSort; import funkin.freeplayStuff.SongMenuItem; import funkin.play.HealthIcon; import funkin.play.PlayState; -import funkin.play.song.SongData.SongDataParser; import funkin.shaderslmfao.AngleMask; import funkin.shaderslmfao.PureColor; import funkin.shaderslmfao.StrokeShader; @@ -843,7 +843,8 @@ class FreeplayState extends MusicBeatSubState }*/ PlayStatePlaylist.isStoryMode = false; - var targetSong:Song = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase()); + var songId:String = songs[curSelected].songName.toLowerCase(); + var targetSong:Song = SongRegistry.instance.fetchEntry(songId); var targetDifficulty:String = switch (curDifficulty) { case 0: diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index 82a357ae9..e7060abd7 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -17,11 +17,11 @@ import funkin.play.PlayStatePlaylist; import openfl.display.BitmapData; import funkin.data.level.LevelRegistry; import funkin.data.notestyle.NoteStyleRegistry; -import funkin.play.event.SongEventData.SongEventParser; +import funkin.data.event.SongEventData.SongEventParser; import funkin.play.cutscene.dialogue.ConversationDataParser; import funkin.play.cutscene.dialogue.DialogueBoxDataParser; import funkin.play.cutscene.dialogue.SpeakerDataParser; -import funkin.play.song.SongData.SongDataParser; +import funkin.data.song.SongRegistry; import funkin.play.stage.StageData.StageDataParser; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.modding.module.ModuleHandler; @@ -197,13 +197,13 @@ class InitState extends FlxState // NOTE: Registries and data parsers must be imported and not referenced with fully qualified names, // to ensure build macros work properly. + SongRegistry.instance.loadEntries(); LevelRegistry.instance.loadEntries(); NoteStyleRegistry.instance.loadEntries(); SongEventParser.loadEventCache(); ConversationDataParser.loadConversationCache(); DialogueBoxDataParser.loadDialogueBoxCache(); SpeakerDataParser.loadSpeakerCache(); - SongDataParser.loadSongCache(); StageDataParser.loadStageCache(); CharacterDataParser.loadCharacterCache(); ModuleHandler.buildModuleCallbacks(); @@ -276,7 +276,7 @@ class InitState extends FlxState */ function startSong(songId:String, difficultyId:String = 'normal'):Void { - var songData:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId); + var songData:funkin.play.song.Song = funkin.data.song.SongRegistry.instance.fetchEntry(songId); if (songData == null) { @@ -312,7 +312,7 @@ class InitState extends FlxState var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift(); - var targetSong:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(targetSongId); + var targetSong:funkin.play.song.Song = SongRegistry.instance.fetchEntry(targetSongId); LoadingState.loadAndSwitchState(new funkin.play.PlayState( { diff --git a/source/funkin/LoadingState.hx b/source/funkin/LoadingState.hx index 3ec2e1005..216d9ba74 100644 --- a/source/funkin/LoadingState.hx +++ b/source/funkin/LoadingState.hx @@ -159,7 +159,7 @@ class LoadingState extends MusicBeatState static function getSongPath():String { - return Paths.inst(PlayState.instance.currentSong.songId); + return Paths.inst(PlayState.instance.currentSong.id); } inline static public function loadAndSwitchState(nextState:FlxState, shouldStopMusic = false):Void diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx index 791a4bb9a..f93e5a450 100644 --- a/source/funkin/PauseSubState.hx +++ b/source/funkin/PauseSubState.hx @@ -10,7 +10,7 @@ import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxColor; import funkin.play.PlayState; -import funkin.play.song.SongData.SongDataParser; +import funkin.data.song.SongRegistry; class PauseSubState extends MusicBeatSubState { @@ -197,7 +197,7 @@ class PauseSubState extends MusicBeatSubState regenMenu(); case 'EASY' | 'NORMAL' | 'HARD' | 'ERECT': - PlayState.instance.currentSong = SongDataParser.fetchSong(PlayState.instance.currentSong.songId.toLowerCase()); + PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase()); PlayState.instance.currentDifficulty = daSelected.toLowerCase(); diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx index 98393fda4..24d0de476 100644 --- a/source/funkin/data/BaseRegistry.hx +++ b/source/funkin/data/BaseRegistry.hx @@ -4,6 +4,9 @@ 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. @@ -135,7 +138,7 @@ abstract class BaseRegistry & Constructible { - var entryStr:String = loadEntryFile(id); + var entryStr:String = loadEntryFile(id).contents; var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } @@ -145,11 +148,14 @@ abstract class BaseRegistry & Constructible & Constructible & Constructible; + + 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}'); + } + } } + +typedef JsonFile = +{ + fileName:String, + contents:String +}; diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx new file mode 100644 index 000000000..8a78e7c97 --- /dev/null +++ b/source/funkin/data/DataParse.hx @@ -0,0 +1,99 @@ +package funkin.data; + +import hxjsonast.Json; +import hxjsonast.Json.JObjectField; + +/** + * `json2object` has an annotation `@:jcustomparse` which allows for mutation of parsed values. + * + * It also allows for validation, since throwing an error in this function will cause the issue to be properly caught. + * Parsing will fail and `parser.errors` will contain the thrown exception. + * + * Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property. + */ +class DataParse +{ + /** + * `@:jcustomparse(funkin.data.DataParse.stringNotEmpty)` + * @param json Contains the `pos` and `value` of the property. + * @param name The name of the property. + * @throws If the property is not a string or is empty. + */ + public static function stringNotEmpty(json:Json, name:String):String + { + switch (json.value) + { + case JString(s): + if (s == "") throw 'Expected property $name to be non-empty.'; + return s; + default: + throw 'Expected property $name to be a string, but it was ${json.value}.'; + } + } + + /** + * Parser which outputs a Dynamic value, either a object or something else. + * @param json + * @param name + * @return The value of the property. + */ + public static function dynamicValue(json:Json, name:String):Dynamic + { + return jsonToDynamic(json); + } + + /** + * Parser which outputs a Dynamic value, which must be an object with properties. + * @param json + * @param name + * @return Dynamic + */ + public static function dynamicObject(json:Json, name:String):Dynamic + { + switch (json.value) + { + case JObject(fields): + return jsonFieldsToDynamicObject(fields); + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + static function jsonToDynamic(json:Json):Null + { + return switch (json.value) + { + case JString(s): s; + case JNumber(n): n; + case JBool(b): b; + case JNull: null; + case JObject(fields): jsonFieldsToDynamicObject(fields); + case JArray(values): jsonArrayToDynamicArray(values); + } + } + + /** + * Array of JSON fields `[{key, value}, {key, value}]` to a Dynamic object `{key:value, key:value}`. + * @param fields + * @return Dynamic + */ + static function jsonFieldsToDynamicObject(fields:Array):Dynamic + { + var result:Dynamic = {}; + for (field in fields) + { + Reflect.setField(result, field.name, field.value); + } + return result; + } + + /** + * Array of JSON elements `[Json, Json, Json]` to a Dynamic array `[String, Object, Int, Array]` + * @param jsons + * @return Array + */ + static function jsonArrayToDynamicArray(jsons:Array):Array> + { + return [for (json in jsons) jsonToDynamic(json)]; + } +} diff --git a/source/funkin/data/DataWrite.hx b/source/funkin/data/DataWrite.hx new file mode 100644 index 000000000..2ff7672da --- /dev/null +++ b/source/funkin/data/DataWrite.hx @@ -0,0 +1,8 @@ +package funkin.data; + +/** + * `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 {} diff --git a/source/funkin/data/IRegistryEntry.hx b/source/funkin/data/IRegistryEntry.hx index 0fb704b7c..ff506767d 100644 --- a/source/funkin/data/IRegistryEntry.hx +++ b/source/funkin/data/IRegistryEntry.hx @@ -15,5 +15,6 @@ interface IRegistryEntry // Can't make an interface field private I guess. public final _data:T; - public function _fetchData(id:String):Null; + // Can't make a static field required by an interface I guess. + // private static function _fetchData(id:String):Null; } diff --git a/source/funkin/data/README.md b/source/funkin/data/README.md new file mode 100644 index 000000000..58fa6fa59 --- /dev/null +++ b/source/funkin/data/README.md @@ -0,0 +1,21 @@ +# funkin.data + +Data structures are parsed using `json2object`, which uses macros to generate parser classes based on anonymous structures OR classes. + +Parsing errors will be returned in `parser.errors`. See `json2object.Error` for an enumeration of possible parsing errors. If an error occurred, `parser.value` will be null. + +The properties of these anonymous structures can have their behavior changed with annotations: + +- `@:optional`: The value is optional and will not throw a parsing error if it is not present in the JSON data. +- `@:default("test")`: If the value is optional, this value will be used instead of `null`. Replace `"test"` with a value of the property's type. +- `@:default(auto)`: If the value is an anonymous structure with `json2object` annotations, each field will be initialized to its default value. +- `@:jignored`: This value will be ignored by the parser. Their presence will not be checked in the JSON data and their values will not be parsed. +- `@:alias`: Choose the name the value will use in the JSON data to be separate from the property name. Useful if the desired name is a reserved word like `public`. +- `@:jcustomparse`: Provide a custom function for parsing from a JSON string into a value. + - Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property. + - `hxjsonast.Json` contains a `pos` and a `value`, with `value` being an enum: https://nadako.github.io/hxjsonast/hxjsonast/JsonValue.html + - Errors thrown in this function will cause a parsing error (`CustomFunctionException`) along with a position! + - Make sure to provide the FULLY QUALIFIED path to the custom function. +- `@:jcustomwrite`: Provide a custom function for serializing the property into a string for storage as JSON. + - Functions must be of the signature `(T) -> String`, where `T` is the type of the property. + diff --git a/source/funkin/play/event/SongEventData.hx b/source/funkin/data/event/SongEventData.hx similarity index 97% rename from source/funkin/play/event/SongEventData.hx rename to source/funkin/data/event/SongEventData.hx index 8c157b52a..831a53fbd 100644 --- a/source/funkin/play/event/SongEventData.hx +++ b/source/funkin/data/event/SongEventData.hx @@ -1,7 +1,8 @@ -package funkin.play.event; +package funkin.data.event; -import funkin.play.event.SongEventData.SongEventSchema; -import funkin.play.song.SongData.SongEventData; +import funkin.play.event.SongEvent; +import funkin.data.event.SongEventData.SongEventSchema; +import funkin.data.song.SongData.SongEventData; import funkin.util.macro.ClassMacro; import funkin.play.event.ScriptedSongEvent; diff --git a/source/funkin/data/level/LevelData.hx b/source/funkin/data/level/LevelData.hx index 0ba26354a..843389cae 100644 --- a/source/funkin/data/level/LevelData.hx +++ b/source/funkin/data/level/LevelData.hx @@ -24,6 +24,7 @@ typedef LevelData = /** * The graphic for the level, as seen in the scrolling list. */ + @:jcustomparse(funkin.data.DataParse.stringNotEmpty) var titleAsset:String; @:default([]) @@ -40,6 +41,7 @@ typedef LevelPropData = /** * The image to use for the prop. May optionally be a sprite sheet. */ + // @:jcustomparse(funkin.data.DataParse.stringNotEmpty) var assetPath:String; /** diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx index 36ce883ea..d135e1241 100644 --- a/source/funkin/data/level/LevelRegistry.hx +++ b/source/funkin/data/level/LevelRegistry.hx @@ -30,17 +30,18 @@ class LevelRegistry extends BaseRegistry // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser(); - var jsonStr:String = loadEntryFile(id); - parser.fromJson(jsonStr); + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { - trace('[${registryId}] Failed to parse entry data: ${id}'); - for (error in parser.errors) - { - trace(error); - } + printErrors(parser.errors, id); return null; } return parser.value; diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx index 65f6f627a..bb594bca4 100644 --- a/source/funkin/data/notestyle/NoteStyleRegistry.hx +++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx @@ -34,22 +34,21 @@ class NoteStyleRegistry extends BaseRegistry */ public function parseEntryData(id:String):Null { - if (id == null) id = DEFAULT_NOTE_STYLE_ID; - // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser(); - var jsonStr:String = loadEntryFile(id); - parser.fromJson(jsonStr); + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { - trace('[${registryId}] Failed to parse entry data: ${id}'); - for (error in parser.errors) - { - trace(error); - } + printErrors(parser.errors, id); return null; } return parser.value; diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx new file mode 100644 index 000000000..2e98b9c0a --- /dev/null +++ b/source/funkin/data/song/SongData.hx @@ -0,0 +1,649 @@ +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; + +class SongMetadata +{ + /** + * A semantic versioning string for the song data format. + * + */ + // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION) + 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; + + /** + * Data relating to the song's gameplay. + */ + public var playData:SongPlayData; + + // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS) + public var timeFormat:SongTimeFormat; + + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES) + public var timeChanges:Array; + + /** + * Defaults to `default` or `''`. Populated later. + */ + @:jignored + public var variation:String = 'default'; + + public function new(songName:String, artist:String, variation:String = 'default') + { + this.version = SongMigrator.CHART_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.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = variation; + } + + public function clone(?newVariation:String = null):SongMetadata + { + var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.timeChanges = this.timeChanges; + result.looped = this.looped; + result.playData = this.playData; + result.generatedBy = this.generatedBy; + + return result; + } +} + +enum abstract SongTimeFormat(String) from String to String +{ + var TICKS = 'ticks'; + var FLOAT = 'float'; + var MILLISECONDS = 'ms'; +} + +class SongTimeChange +{ + public static final DEFAULT_SONGTIMECHANGE:SongTimeChange = new SongTimeChange(0, 100); + + public static final DEFAULT_SONGTIMECHANGES:Array = [DEFAULT_SONGTIMECHANGE]; + + static final DEFAULT_BEAT_TUPLETS:Array = [4, 4, 4, 4]; + static final DEFAULT_BEAT_TIME:Null = null; // Later, null gets detected and recalculated. + + /** + * Timestamp in specified `timeFormat`. + */ + @:alias("t") + public var timeStamp:Float; + + /** + * Time in beats (int). The game will calculate further beat values based on this one, + * so it can do it in a simple linear fashion. + */ + @:optional + @:alias("b") + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_BEAT_TIME) + public var beatTime:Null; + + /** + * Quarter notes per minute (float). Cannot be empty in the first element of the list, + * but otherwise it's optional, and defaults to the value of the previous element. + */ + @:alias("bpm") + public var bpm:Float; + + /** + * Time signature numerator (int). Optional, defaults to 4. + */ + @:default(4) + @:optional + @:alias("n") + public var timeSignatureNum:Int; + + /** + * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. + */ + @:default(4) + @:optional + @:alias("d") + public var timeSignatureDen:Int; + + /** + * Beat tuplets (Array or int). This defines how many steps each beat is divided into. + * It can either be an array of length `n` (see above) or a single integer number. + * Optional, defaults to `[4]`. + */ + @:optional + @:alias("bt") + public var beatTuplets:Array; + + public function new(timeStamp:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, ?beatTime:Float, ?beatTuplets:Array) + { + this.timeStamp = timeStamp; + this.bpm = bpm; + + this.timeSignatureNum = timeSignatureNum; + this.timeSignatureDen = timeSignatureDen; + + this.beatTime = beatTime == null ? DEFAULT_BEAT_TIME : beatTime; + this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets; + } +} + +/** + * Metadata for a song only used for the music. + * For example, the menu music. + */ +class SongMusicData +{ + /** + * A semantic versioning string for the song data format. + * + */ + // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION) + 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; + + // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS) + public var timeFormat:SongTimeFormat; + + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES) + public var timeChanges:Array; + + /** + * Defaults to `default` or `''`. Populated later. + */ + @:jignored + public var variation:String = 'default'; + + public function new(songName:String, artist:String, variation:String = 'default') + { + this.version = SongMigrator.CHART_VERSION; + this.songName = songName; + this.artist = artist; + this.timeFormat = 'ms'; + this.divisions = null; + this.timeChanges = [new SongTimeChange(0, 100)]; + this.looped = false; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = variation; + } + + public function clone(?newVariation:String = null):SongMusicData + { + var result:SongMusicData = new SongMusicData(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.timeChanges = this.timeChanges; + result.looped = this.looped; + result.generatedBy = this.generatedBy; + + return result; + } +} + +typedef SongPlayData = +{ + public var songVariations:Array; + public var difficulties:Array; + + /** + * Keys are the player characters and the values give info on what opponent/GF/inst to use. + */ + public var playableChars:Map; + + public var stage:String; + public var noteSkin:String; +} + +class SongPlayableChar +{ + @: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; + } +} + +class SongChartData +{ + @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION) + public var version:Version; + + public var scrollSpeed:Map; + public var events:Array; + public var notes:Map>; + + @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + public function new(scrollSpeed:Map, events:Array, notes:Map>) + { + this.version = SongRegistry.SONG_CHART_DATA_VERSION; + + this.events = events; + this.notes = notes; + this.scrollSpeed = scrollSpeed; + + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + } + + public function getScrollSpeed(diff:String = 'default'):Float + { + var result:Float = this.scrollSpeed.get(diff); + + if (result == 0.0 && diff != 'default') return getScrollSpeed('default'); + + return (result == 0.0) ? 1.0 : result; + } + + public function setScrollSpeed(value:Float, diff:String = 'default'):Float + { + this.scrollSpeed.set(diff, value); + return value; + } + + public function getNotes(diff:String):Array + { + var result:Array = this.notes.get(diff); + + if (result == null && diff != 'normal') return getNotes('normal'); + + return (result == null) ? [] : result; + } + + public function setNotes(value:Array, diff:String):Array + { + this.notes.set(diff, value); + return value; + } + + public function getEvents():Array + { + return this.events; + } + + public function setEvents(value:Array):Array + { + return this.events = value; + } +} + +class SongEventData +{ + /** + * The timestamp of the event. The timestamp is in the format of the song's time format. + */ + @:alias("t") + public var time:Float; + + /** + * The kind of the event. + * Examples include "FocusCamera" and "PlayAnimation" + * Custom events can be added by scripts with the `ScriptedSongEvent` class. + */ + @:alias("e") + public var event:String; + + /** + * The data for the event. + * This can allow the event to include information used for custom behavior. + * Data type depends on the event kind. It can be anything that's JSON serializable. + */ + @:alias("v") + @:optional + @:jcustomparse(funkin.data.DataParse.dynamicValue) + public var value:Dynamic = null; + + /** + * Whether this event has been activated. + * This is only used internally by the game. It should not be serialized. + */ + @:jignored + public var activated:Bool = false; + + public function new(time:Float, event:String, value:Dynamic = null) + { + this.time = time; + this.event = event; + this.value = value; + } + + @:jignored + public var stepTime(get, never):Float; + + function get_stepTime():Float + { + return Conductor.getTimeInSteps(this.time); + } + + public inline function getDynamic(key:String):Null + { + return value == null ? null : Reflect.field(value, key); + } + + public inline function getBool(key:String):Null + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getInt(key:String):Null + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getFloat(key:String):Null + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getString(key:String):String + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getArray(key:String):Array + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getBoolArray(key:String):Array + { + return value == null ? null : cast Reflect.field(value, key); + } + + @:op(A == B) + public function op_equals(other:SongEventData):Bool + { + return this.time == other.time && this.event == other.event && this.value == other.value; + } + + @:op(A != B) + public function op_notEquals(other:SongEventData):Bool + { + return this.time != other.time || this.event != other.event || this.value != other.value; + } + + @:op(A > B) + public function op_greaterThan(other:SongEventData):Bool + { + return this.time > other.time; + } + + @:op(A < B) + public function op_lessThan(other:SongEventData):Bool + { + return this.time < other.time; + } + + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongEventData):Bool + { + return this.time >= other.time; + } + + @:op(A <= B) + public function op_lessThanOrEquals(other:SongEventData):Bool + { + return this.time <= other.time; + } +} + +class SongNoteData +{ + /** + * The timestamp of the note. The timestamp is in the format of the song's time format. + */ + @:alias("t") + public var time:Float; + + /** + * Data for the note. Represents the index on the strumline. + * 0 = left, 1 = down, 2 = up, 3 = right + * `floor(direction / strumlineSize)` specifies which strumline the note is on. + * 0 = player, 1 = opponent, etc. + */ + @:alias("d") + public var data:Int; + + /** + * Length of the note, if applicable. + * Defaults to 0 for single notes. + */ + @:alias("l") + @:default(0) + @:optional + public var length:Float; + + /** + * The kind of the note. + * This can allow the note to include information used for custom behavior. + * Defaults to blank or `"normal"`. + */ + @:alias("k") + @:default("normal") + @:optional + public var kind(get, default):String = ''; + + function get_kind():String + { + if (this.kind == null || this.kind == '') return 'normal'; + + return this.kind; + } + + public function new(time:Float, data:Int, length:Float = 0, kind:String = '') + { + this.time = time; + this.data = data; + this.length = length; + this.kind = kind; + } + + /** + * The timestamp of the note, in steps. + */ + @:jignored + public var stepTime(get, never):Float; + + function get_stepTime():Float + { + return Conductor.getTimeInSteps(this.time); + } + + /** + * The direction of the note, if applicable. + * Strips the strumline index from the data. + * + * 0 = left, 1 = down, 2 = up, 3 = right + */ + public inline function getDirection(strumlineSize:Int = 4):Int + { + return this.data % strumlineSize; + } + + public function getDirectionName(strumlineSize:Int = 4):String + { + switch (this.data % strumlineSize) + { + case 0: + return 'Left'; + case 1: + return 'Down'; + case 2: + return 'Up'; + case 3: + return 'Right'; + default: + return 'Unknown'; + } + } + + /** + * The strumline index of the note, if applicable. + * Strips the direction from the data. + * + * 0 = player, 1 = opponent, etc. + */ + public inline function getStrumlineIndex(strumlineSize:Int = 4):Int + { + return Math.floor(this.data / strumlineSize); + } + + /** + * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side). + * TODO: The name of this function is a little misleading; what about mines? + * @param strumlineSize Defaults to 4. + * @return True if it's Boyfriend's note. + */ + public inline function getMustHitNote(strumlineSize:Int = 4):Bool + { + return getStrumlineIndex(strumlineSize) == 0; + } + + /** + * If this is a hold note, this is the length of the hold note in steps. + * @default 0 (not a hold note) + */ + public var stepLength(get, set):Float; + + function get_stepLength():Float + { + return Conductor.getTimeInSteps(this.time + this.length) - this.stepTime; + } + + function set_stepLength(value:Float):Float + { + return this.length = Conductor.getStepTimeInMs(value) - this.time; + } + + @:jignored + public var isHoldNote(get, never):Bool; + + public function get_isHoldNote():Bool + { + return this.length > 0; + } + + @:op(A == B) + public function op_equals(other:SongNoteData):Bool + { + if (this.kind == '') + { + if (other.kind != '' && other.kind != 'normal') return false; + } + else + { + if (other.kind == '' || other.kind != this.kind) return false; + } + + return this.time == other.time && this.data == other.data && this.length == other.length; + } + + @:op(A != B) + public function op_notEquals(other:SongNoteData):Bool + { + if (this.kind == '') + { + if (other.kind != '' && other.kind != 'normal') return true; + } + else + { + if (other.kind == '' || other.kind != this.kind) return true; + } + + return this.time != other.time || this.data != other.data || this.length != other.length; + } + + @:op(A > B) + public function op_greaterThan(other:SongNoteData):Bool + { + return this.time > other.time; + } + + @:op(A < B) + public function op_lessThan(other:SongNoteData):Bool + { + return this.time < other.time; + } + + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongNoteData):Bool + { + return this.time >= other.time; + } + + @:op(A <= B) + public function op_lessThanOrEquals(other:SongNoteData):Bool + { + return this.time <= other.time; + } +} diff --git a/source/funkin/play/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx similarity index 98% rename from source/funkin/play/song/SongDataUtils.hx rename to source/funkin/data/song/SongDataUtils.hx index a7cbd1b6c..d15a2b19a 100644 --- a/source/funkin/play/song/SongDataUtils.hx +++ b/source/funkin/data/song/SongDataUtils.hx @@ -1,8 +1,8 @@ -package funkin.play.song; +package funkin.data.song; import flixel.util.FlxSort; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; import funkin.util.ClipboardUtil; import funkin.util.SerializerUtil; diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx new file mode 100644 index 000000000..e21c74a1f --- /dev/null +++ b/source/funkin/data/song/SongRegistry.hx @@ -0,0 +1,262 @@ +package funkin.data.song; + +import funkin.data.song.SongData; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; +import funkin.play.song.ScriptedSong; +import funkin.play.song.Song; +import funkin.util.assets.DataAssets; +import funkin.util.VersionUtil; + +class SongRegistry extends BaseRegistry +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final SONG_METADATA_VERSION:thx.semver.Version = "2.0.0"; + + public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.0.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 var DEFAULT_GENERATEDBY(get, null):String; + + static function get_DEFAULT_GENERATEDBY():String + { + return '${Constants.TITLE} - ${Constants.VERSION}'; + } + + public static final instance:SongRegistry = new SongRegistry(); + + public function new() + { + super('SONG', 'songs', SONG_METADATA_VERSION_RULE); + } + + public override function loadEntries():Void + { + clearEntries(); + + // + // SCRIPTED ENTRIES + // + var scriptedEntryClassNames:Array = getScriptedClassNames(); + log('Registering ${scriptedEntryClassNames.length} scripted entries...'); + + for (entryCls in scriptedEntryClassNames) + { + var entry:Song = createScriptedEntry(entryCls); + + if (entry != null) + { + log('Successfully created scripted entry (${entryCls} = ${entry.id})'); + entries.set(entry.id, entry); + } + else + { + log('Failed to create scripted entry (${entryCls})'); + } + } + + // + // UNSCRIPTED ENTRIES + // + var entryIdList:Array = DataAssets.listDataFilesInPath('songs/', '-metadata.json').map(function(songDataPath:String):String { + return songDataPath.split('/')[0]; + }); + var unscriptedEntryIds:Array = entryIdList.filter(function(entryId:String):Bool { + return !entries.exists(entryId); + }); + log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...'); + for (entryId in unscriptedEntryIds) + { + try + { + var entry:Song = createEntry(entryId); + if (entry != null) + { + trace(' Loaded entry data: ${entry}'); + entries.set(entry.id, entry); + } + } + catch (e:Dynamic) + { + // Print the error. + trace(' Failed to load entry data: ${entryId}'); + trace(e); + continue; + } + } + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null + { + return parseEntryMetadata(id); + } + + public function parseEntryMetadata(id:String, variation:String = ""):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + + var parser = new json2object.JsonParser(); + switch (loadEntryMetadataFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryMetadataWithMigration(id:String, variation:String = '', 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 parseEntryMetadata(id); + } + else + { + throw '[${registryId}] Metadata entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.'; + } + } + + public function parseMusicData(id:String, variation:String = ""):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + + var parser = new json2object.JsonParser(); + switch (loadMusicDataFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryChartData(id:String, variation:String = ''):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser(); + + switch (loadEntryChartFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryChartDataWithMigration(id:String, variation:String = '', 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 parseEntryChartData(id, variation); + } + else + { + throw '[${registryId}] Chart entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + } + } + + function createScriptedEntry(clsName:String):Song + { + return ScriptedSong.init(clsName, "unknown"); + } + + function getScriptedClassNames():Array + { + return ScriptedSong.listScriptClasses(); + } + + function loadEntryMetadataFile(id:String, variation:String = ''):BaseRegistry.JsonFile + { + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-metadata'); + var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + function loadMusicDataFile(id:String, variation:String = ''):BaseRegistry.JsonFile + { + var entryFilePath:String = Paths.file('music/$id/$id${variation == '' ? '' : '-$variation'}-metadata.json'); + var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + function loadEntryChartFile(id:String, variation:String = ''):BaseRegistry.JsonFile + { + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-chart'); + var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null + { + var entryStr:String = loadEntryMetadataFile(id, variation).contents; + var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + return entryVersion; + } + + public function fetchEntryChartVersion(id:String, variation:String = ''):Null + { + var entryStr:String = loadEntryChartFile(id, variation).contents; + var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + return entryVersion; + } + + /** + * A list of all the story weeks from the base game, in order. + * TODO: Should this be hardcoded? + */ + public function listBaseGameSongIds():Array + { + return [ + "tutorial", "bopeebo", "fresh", "dadbattle", "spookeez", "south", "monster", "pico", "philly-nice", "blammed", "satin-panties", "high", "milf", "cocoa", + "eggnog", "winter-horrorland", "senpai", "roses", "thorns", "ugh", "guns", "stress", "darnell", "lit-up", "2hot", "blazin" + ]; + } + + /** + * A list of all installed story weeks that are not from the base game. + */ + public function listModdedSongIds():Array + { + return listEntryIds().filter(function(id:String):Bool { + return listBaseGameSongIds().indexOf(id) == -1; + }); + } +} diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index 47afb0a30..f7f69428b 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -3,18 +3,19 @@ package funkin.modding; import funkin.util.macro.ClassMacro; import funkin.modding.module.ModuleHandler; import funkin.play.character.CharacterData.CharacterDataParser; -import funkin.play.song.SongData; +import funkin.data.song.SongData; import funkin.play.stage.StageData; import polymod.Polymod; import polymod.backends.PolymodAssets.PolymodAssetType; import polymod.format.ParseRules.TextFileFormat; -import funkin.play.event.SongEventData.SongEventParser; +import funkin.data.event.SongEventData.SongEventParser; import funkin.util.FileUtil; import funkin.data.level.LevelRegistry; import funkin.data.notestyle.NoteStyleRegistry; import funkin.play.cutscene.dialogue.ConversationDataParser; import funkin.play.cutscene.dialogue.DialogueBoxDataParser; import funkin.play.cutscene.dialogue.SpeakerDataParser; +import funkin.data.song.SongRegistry; class PolymodHandler { @@ -290,13 +291,13 @@ class PolymodHandler // These MUST be imported at the top of the file and not referred to by fully qualified name, // to ensure build macros work properly. + SongRegistry.instance.loadEntries(); LevelRegistry.instance.loadEntries(); NoteStyleRegistry.instance.loadEntries(); SongEventParser.loadEventCache(); ConversationDataParser.loadConversationCache(); DialogueBoxDataParser.loadDialogueBoxCache(); SpeakerDataParser.loadSpeakerCache(); - SongDataParser.loadSongCache(); StageDataParser.loadStageCache(); CharacterDataParser.loadCharacterCache(); ModuleHandler.loadModuleCache(); diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx index 3f29ad833..586a6206c 100644 --- a/source/funkin/modding/events/ScriptEvent.hx +++ b/source/funkin/modding/events/ScriptEvent.hx @@ -1,6 +1,6 @@ package funkin.modding.events; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongNoteData; import flixel.FlxState; import flixel.FlxSubState; import funkin.play.notes.NoteSprite; @@ -435,9 +435,9 @@ class SongEventScriptEvent extends ScriptEvent * The note associated with this event. * You cannot replace it, but you can edit it. */ - public var event(default, null):funkin.play.song.SongData.SongEventData; + public var event(default, null):funkin.data.song.SongData.SongEventData; - public function new(event:funkin.play.song.SongData.SongEventData):Void + public function new(event:funkin.data.song.SongData.SongEventData):Void { super(ScriptEvent.SONG_EVENT, true); this.event = event; diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 068f32f97..46938215b 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -35,7 +35,7 @@ import funkin.play.cutscene.dialogue.Conversation; import funkin.play.cutscene.dialogue.ConversationDataParser; import funkin.play.cutscene.VanillaCutscenes; import funkin.play.cutscene.VideoCutscene; -import funkin.play.event.SongEventData.SongEventParser; +import funkin.data.event.SongEventData.SongEventParser; import funkin.play.notes.NoteSprite; import funkin.play.notes.NoteDirection; import funkin.play.notes.Strumline; @@ -43,10 +43,10 @@ import funkin.play.notes.SustainTrail; import funkin.play.scoring.Scoring; import funkin.NoteSplash; import funkin.play.song.Song; -import funkin.play.song.SongData.SongDataParser; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongNoteData; -import funkin.play.song.SongData.SongPlayableChar; +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.play.stage.Stage; import funkin.play.stage.StageData.StageDataParser; import funkin.ui.PopUpStuff; @@ -630,7 +630,7 @@ class PlayState extends MusicBeatSubState startingSong = true; // TODO: We hardcoded the transition into Winter Horrorland. Do this with a ScriptedSong instead. - if ((currentSong?.songId ?? '').toLowerCase() == 'winter-horrorland') + if ((currentSong?.id ?? '').toLowerCase() == 'winter-horrorland') { // VanillaCutscenes will call startCountdown later. VanillaCutscenes.playHorrorStartCutscene(); @@ -2495,9 +2495,9 @@ class PlayState extends MusicBeatSubState if (currentSong != null && currentSong.validScore) { // crackhead double thingie, sets whether was new highscore, AND saves the song! - Highscore.tallies.isNewHighscore = Highscore.saveScoreForDifficulty(currentSong.songId, songScore, currentDifficulty); + Highscore.tallies.isNewHighscore = Highscore.saveScoreForDifficulty(currentSong.id, songScore, currentDifficulty); - Highscore.saveCompletionForDifficulty(currentSong.songId, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, currentDifficulty); + Highscore.saveCompletionForDifficulty(currentSong.id, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, currentDifficulty); } if (PlayStatePlaylist.isStoryMode) @@ -2549,7 +2549,7 @@ class PlayState extends MusicBeatSubState vocals.stop(); // TODO: Softcode this cutscene. - if (currentSong.songId == 'eggnog') + if (currentSong.id == 'eggnog') { var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom, -FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK); @@ -2560,7 +2560,7 @@ class PlayState extends MusicBeatSubState FlxG.sound.play(Paths.sound('Lights_Shut_off'), function() { // no camFollow so it centers on horror tree - var targetSong:Song = SongDataParser.fetchSong(targetSongId); + var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId); // Load and cache the song's charts. // TODO: Do this in the loading state. targetSong.cacheCharts(true); @@ -2577,7 +2577,7 @@ class PlayState extends MusicBeatSubState } else { - var targetSong:Song = SongDataParser.fetchSong(targetSongId); + var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId); // Load and cache the song's charts. // TODO: Do this in the loading state. targetSong.cacheCharts(true); diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx index aaa2b6d1d..0c2984719 100644 --- a/source/funkin/play/ResultState.hx +++ b/source/funkin/play/ResultState.hx @@ -143,7 +143,7 @@ class ResultState extends MusicBeatSubState } else { - songName.text += PlayState.instance.currentSong.songId; + songName.text += PlayState.instance.currentSong.id; } songName.letterSpacing = -15; diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx index 8d677118b..5f63254b0 100644 --- a/source/funkin/play/event/FocusCameraSongEvent.hx +++ b/source/funkin/play/event/FocusCameraSongEvent.hx @@ -1,9 +1,12 @@ package funkin.play.event; -import funkin.play.song.SongData; +// Data from the chart +import funkin.data.song.SongData; +import funkin.data.song.SongData.SongEventData; +// Data from the event schema import funkin.play.event.SongEvent; -import funkin.play.event.SongEventData.SongEventFieldType; -import funkin.play.event.SongEventData.SongEventSchema; +import funkin.data.event.SongEventData.SongEventSchema; +import funkin.data.event.SongEventData.SongEventFieldType; /** * This class represents a handler for a type of song event. diff --git a/source/funkin/play/event/PlayAnimationSongEvent.hx b/source/funkin/play/event/PlayAnimationSongEvent.hx index a187ca285..6bc625517 100644 --- a/source/funkin/play/event/PlayAnimationSongEvent.hx +++ b/source/funkin/play/event/PlayAnimationSongEvent.hx @@ -2,10 +2,13 @@ package funkin.play.event; import flixel.FlxSprite; import funkin.play.character.BaseCharacter; +// Data from the chart +import funkin.data.song.SongData; +import funkin.data.song.SongData.SongEventData; +// Data from the event schema import funkin.play.event.SongEvent; -import funkin.play.event.SongEventData.SongEventFieldType; -import funkin.play.event.SongEventData.SongEventSchema; -import funkin.play.song.SongData; +import funkin.data.event.SongEventData.SongEventSchema; +import funkin.data.event.SongEventData.SongEventFieldType; class PlayAnimationSongEvent extends SongEvent { diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx index b17d4511c..3cdeb9a67 100644 --- a/source/funkin/play/event/SetCameraBopSongEvent.hx +++ b/source/funkin/play/event/SetCameraBopSongEvent.hx @@ -3,10 +3,13 @@ package funkin.play.event; import flixel.tweens.FlxTween; import flixel.FlxCamera; import flixel.tweens.FlxEase; +// Data from the chart +import funkin.data.song.SongData; +import funkin.data.song.SongData.SongEventData; +// Data from the event schema import funkin.play.event.SongEvent; -import funkin.play.song.SongData; -import funkin.play.event.SongEventData; -import funkin.play.event.SongEventData.SongEventFieldType; +import funkin.data.event.SongEventData.SongEventSchema; +import funkin.data.event.SongEventData.SongEventFieldType; /** * This class represents a handler for configuring camera bop intensity and rate. diff --git a/source/funkin/play/event/SongEvent.hx b/source/funkin/play/event/SongEvent.hx index 6acc745ff..36a886673 100644 --- a/source/funkin/play/event/SongEvent.hx +++ b/source/funkin/play/event/SongEvent.hx @@ -1,7 +1,7 @@ package funkin.play.event; -import funkin.play.song.SongData.SongEventData; -import funkin.play.event.SongEventData.SongEventSchema; +import funkin.data.song.SongData.SongEventData; +import funkin.data.event.SongEventData.SongEventSchema; /** * This class represents a handler for a type of song event. diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx index 79425d564..1ae76039e 100644 --- a/source/funkin/play/event/ZoomCameraSongEvent.hx +++ b/source/funkin/play/event/ZoomCameraSongEvent.hx @@ -3,10 +3,13 @@ package funkin.play.event; import flixel.tweens.FlxTween; import flixel.FlxCamera; import flixel.tweens.FlxEase; +// Data from the chart +import funkin.data.song.SongData; +import funkin.data.song.SongData.SongEventData; +// Data from the event schema import funkin.play.event.SongEvent; -import funkin.play.song.SongData; -import funkin.play.event.SongEventData; -import funkin.play.event.SongEventData.SongEventFieldType; +import funkin.data.event.SongEventData.SongEventFieldType; +import funkin.data.event.SongEventData.SongEventSchema; /** * This class represents a handler for camera zoom events. @@ -76,8 +79,7 @@ class ZoomCameraSongEvent extends SongEvent return; } - FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.stepLengthMs * duration / 1000), - {ease: easeFunction}); + FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.stepLengthMs * duration / 1000), {ease: easeFunction}); } } diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx index 25b23eee7..e6202a3a0 100644 --- a/source/funkin/play/notes/NoteSprite.hx +++ b/source/funkin/play/notes/NoteSprite.hx @@ -1,6 +1,6 @@ package funkin.play.notes; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongNoteData; import funkin.play.notes.notestyle.NoteStyle; import flixel.graphics.frames.FlxAtlasFrames; import flixel.FlxSprite; diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index 2b21e6b7e..7bd6e7ae7 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -11,7 +11,7 @@ import funkin.play.notes.NoteHoldCover; import funkin.play.notes.NoteSplash; import funkin.play.notes.NoteSprite; import funkin.play.notes.SustainTrail; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongNoteData; import funkin.ui.PreferencesMenu; import funkin.util.SortUtil; diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx index 4bcbe0528..0b9304a3d 100644 --- a/source/funkin/play/notes/SustainTrail.hx +++ b/source/funkin/play/notes/SustainTrail.hx @@ -2,7 +2,7 @@ package funkin.play.notes; import funkin.play.notes.notestyle.NoteStyle; import funkin.play.notes.NoteDirection; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongNoteData; import flixel.util.FlxDirectionFlags; import flixel.FlxSprite; import flixel.graphics.FlxGraphic; diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx index 97871b657..34c1ce9c3 100644 --- a/source/funkin/play/notes/notestyle/NoteStyle.hx +++ b/source/funkin/play/notes/notestyle/NoteStyle.hx @@ -104,7 +104,8 @@ class NoteStyle implements IRegistryEntry noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary()); - if (noteFrames == null) { + if (noteFrames == null) + { throw 'Could not load note frames for note style: $id'; } @@ -139,13 +140,13 @@ class NoteStyle implements IRegistryEntry function buildNoteAnimations(target:NoteSprite):Void { var leftData:AnimationData = fetchNoteAnimationData(LEFT); - target.animation.addByPrefix('purpleScroll', leftData.prefix); + target.animation.addByPrefix('purpleScroll', leftData.prefix, leftData.frameRate, leftData.looped, leftData.flipX, leftData.flipY); var downData:AnimationData = fetchNoteAnimationData(DOWN); - target.animation.addByPrefix('blueScroll', downData.prefix); + target.animation.addByPrefix('blueScroll', downData.prefix, downData.frameRate, downData.looped, downData.flipX, downData.flipY); var upData:AnimationData = fetchNoteAnimationData(UP); - target.animation.addByPrefix('greenScroll', upData.prefix); + target.animation.addByPrefix('greenScroll', upData.prefix, upData.frameRate, upData.looped, upData.flipX, upData.flipY); var rightData:AnimationData = fetchNoteAnimationData(RIGHT); - target.animation.addByPrefix('redScroll', rightData.prefix); + target.animation.addByPrefix('redScroll', rightData.prefix, rightData.frameRate, rightData.looped, rightData.flipX, rightData.flipY); } function fetchNoteAnimationData(dir:NoteDirection):AnimationData @@ -302,7 +303,7 @@ class NoteStyle implements IRegistryEntry return 'NoteStyle($id)'; } - public function _fetchData(id:String):Null + static function _fetchData(id:String):Null { return NoteStyleRegistry.instance.parseEntryDataWithMigration(id, NoteStyleRegistry.instance.fetchEntryVersion(id)); } diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 715629a51..b008f6a8e 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -5,14 +5,16 @@ import openfl.utils.Assets; import funkin.modding.events.ScriptEvent; import funkin.modding.IScriptedClass; import funkin.audio.VoicesGroup; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongDataParser; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongMetadata; -import funkin.play.song.SongData.SongNoteData; -import funkin.play.song.SongData.SongPlayableChar; -import funkin.play.song.SongData.SongTimeChange; -import funkin.play.song.SongData.SongTimeFormat; +import funkin.data.song.SongRegistry; +import funkin.data.song.SongData.SongChartData; +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.SongTimeChange; +import funkin.data.song.SongData.SongTimeFormat; +import funkin.data.IRegistryEntry; /** * This is a data structure managing information about the current song. @@ -23,9 +25,26 @@ import funkin.play.song.SongData.SongTimeFormat; * It also receives script events; scripted classes which extend this class * can be used to perform custom gameplay behaviors only on specific songs. */ -class Song implements IPlayStateScriptedClass +@:nullSafety +class Song implements IPlayStateScriptedClass implements IRegistryEntry { - public final songId:String; + 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 final id:String; + + /** + * Song metadata as parsed from the JSON file. + * This is the data for the `default` variation specifically, + * and is needed for the IRegistryEntry interface. + * Will only be null if the song data could not be loaded. + */ + public final _data:Null; final _metadata:Array; @@ -39,33 +58,56 @@ class Song implements IPlayStateScriptedClass var difficultyIds:Array; + public var songName(get, never):String; + + function get_songName():String + { + if (_data != null) return _data?.songName ?? DEFAULT_SONGNAME; + if (_metadata.length > 0) return _metadata[0]?.songName ?? DEFAULT_SONGNAME; + return DEFAULT_SONGNAME; + } + + public var songArtist(get, never):String; + + function get_songArtist():String + { + if (_data != null) return _data?.artist ?? DEFAULT_ARTIST; + if (_metadata.length > 0) return _metadata[0]?.artist ?? DEFAULT_ARTIST; + return DEFAULT_ARTIST; + } + /** * @param id The ID of the song to load. * @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded. */ - public function new(id:String, ignoreErrors:Bool = false) + public function new(id:String) { - this.songId = id; + this.id = id; variations = []; difficultyIds = []; difficulties = new Map(); - try + _data = _fetchData(id); + + _metadata = _data == null ? [] : [_data]; + + for (meta in fetchVariationMetadata(id)) + _metadata.push(meta); + + if (_metadata.length == 0) { - _metadata = SongDataParser.loadSongMetadata(songId); - } - catch (e) - { - _metadata = []; + trace('[WARN] Could not find song data for songId: $id'); + return; } - if (_metadata.length == 0 && !ignoreErrors) - { - throw 'Could not find song data for songId: $songId'; - } - else + variations.clear(); + variations.push('default'); + if (_data != null && _data.playData != null) { + for (vari in _data.playData.songVariations) + variations.push(vari); + populateFromMetadata(); } } @@ -74,7 +116,7 @@ class Song implements IPlayStateScriptedClass public static function buildRaw(songId:String, metadata:Array, variations:Array, charts:Map, validScore:Bool = false):Song { - var result:Song = new Song(songId, true); + var result:Song = new Song(songId); result._metadata.clear(); for (meta in metadata) @@ -112,6 +154,8 @@ class Song implements IPlayStateScriptedClass // Variations may have different artist, time format, generatedBy, etc. for (metadata in _metadata) { + if (metadata == null || metadata.playData == null) continue; + // There may be more difficulties in the chart file than in the metadata, // (i.e. non-playable charts like the one used for Pico on the speaker in Stress) // but all the difficulties in the metadata must be in the chart file. @@ -134,15 +178,16 @@ class Song implements IPlayStateScriptedClass difficulty.stage = metadata.playData.stage; // difficulty.noteSkin = metadata.playData.noteSkin; + difficulties.set(diffId, difficulty); + difficulty.chars = new Map(); + if (metadata.playData.playableChars == null) continue; for (charId in metadata.playData.playableChars.keys()) { - var char = metadata.playData.playableChars.get(charId); - + var char:Null = metadata.playData.playableChars.get(charId); + if (char == null) continue; difficulty.chars.set(charId, char); } - - difficulties.set(diffId, difficulty); } } } @@ -157,11 +202,14 @@ class Song implements IPlayStateScriptedClass clearCharts(); } - trace('Caching ${variations.length} chart files for song $songId'); + trace('Caching ${variations.length} chart files for song $id'); for (variation in variations) { - var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation); - applyChartData(chartData, variation); + var version:Null = SongRegistry.instance.fetchEntryChartVersion(id, variation); + if (version == null) continue; + var chart:Null = SongRegistry.instance.parseEntryChartDataWithMigration(id, version, variation); + if (chart == null) continue; + applyChartData(chart, variation); } trace('Done caching charts.'); } @@ -181,8 +229,8 @@ class Song implements IPlayStateScriptedClass difficulties.set(diffId, difficulty); } // Add the chart data to the difficulty. - difficulty.notes = chartData.notes.get(diffId); - difficulty.scrollSpeed = chartData.getScrollSpeed(diffId); + difficulty.notes = chartNotes.get(diffId) ?? []; + difficulty.scrollSpeed = chartData.getScrollSpeed(diffId) ?? 1.0; difficulty.events = chartData.events; } @@ -193,7 +241,7 @@ class Song implements IPlayStateScriptedClass * @param diffId The difficulty ID, such as `easy` or `hard`. * @return The difficulty data. */ - public inline function getDifficulty(diffId:String = null):SongDifficulty + public inline function getDifficulty(?diffId:String):Null { if (diffId == null) diffId = difficulties.keys().array()[0]; @@ -223,9 +271,11 @@ class Song implements IPlayStateScriptedClass public function toString():String { - return 'Song($songId)'; + return 'Song($id)'; } + public function destroy():Void {} + public function onPause(event:PauseScriptEvent):Void {}; public function onResume(event:ScriptEvent):Void {}; @@ -265,6 +315,27 @@ class Song implements IPlayStateScriptedClass public function onDestroy(event:ScriptEvent):Void {}; public function onUpdate(event:UpdateScriptEvent):Void {}; + + static function _fetchData(id:String):Null + { + trace('Fetching song metadata for $id'); + var version:Null = SongRegistry.instance.fetchEntryMetadataVersion(id); + if (version == null) return null; + return SongRegistry.instance.parseEntryMetadataWithMigration(id, '', version); + } + + function fetchVariationMetadata(id:String):Array + { + var result:Array = []; + for (vari in variations) + { + var version:Null = SongRegistry.instance.fetchEntryMetadataVersion(id, vari); + if (version == null) continue; + var meta:Null = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version); + if (meta != null) result.push(meta); + } + return result; + } } class SongDifficulty @@ -299,7 +370,7 @@ class SongDifficulty public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT; public var divisions:Null = SongValidator.DEFAULT_DIVISIONS; public var looped:Bool = SongValidator.DEFAULT_LOOPED; - public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY; + public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY; public var timeChanges:Array = []; @@ -351,18 +422,18 @@ class SongDifficulty var currentPlayer:Null = getPlayableChar(currentPlayerId); if (currentPlayer != null) { - FlxG.sound.cache(Paths.inst(this.song.songId, currentPlayer.inst)); + FlxG.sound.cache(Paths.inst(this.song.id, currentPlayer.inst)); } else { - FlxG.sound.cache(Paths.inst(this.song.songId)); + FlxG.sound.cache(Paths.inst(this.song.id)); } } public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void { var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; - FlxG.sound.playMusic(Paths.inst(this.song.songId, suffix), volume, looped); + FlxG.sound.playMusic(Paths.inst(this.song.id, suffix), volume, looped); } /** @@ -388,7 +459,7 @@ class SongDifficulty var playableCharData:SongPlayableChar = getPlayableChar(id); if (playableCharData == null) { - trace('Could not find playable char $id for song ${this.song.songId}'); + trace('Could not find playable char $id for song ${this.song.id}'); return []; } @@ -398,24 +469,24 @@ class SongDifficulty // 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.songId, '-$id$suffix'); + var voicePlayer:String = Paths.voices(this.song.id, '-$id$suffix'); while (voicePlayer != null && !Assets.exists(voicePlayer)) { // Remove the last suffix. // For example, bf-car becomes bf. playerId = playerId.split('-').slice(0, -1).join('-'); // Try again. - voicePlayer = playerId == '' ? null : Paths.voices(this.song.songId, '-${playerId}$suffix'); + voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); } var opponentId:String = playableCharData.opponent; - var voiceOpponent:String = Paths.voices(this.song.songId, '-${opponentId}$suffix'); + var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix'); while (voiceOpponent != null && !Assets.exists(voiceOpponent)) { // Remove the last suffix. opponentId = opponentId.split('-').slice(0, -1).join('-'); // Try again. - voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.songId, '-${opponentId}$suffix'); + voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); } var result:Array = []; @@ -424,7 +495,7 @@ class SongDifficulty if (voicePlayer == null && voiceOpponent == null) { // Try to use `Voices.ogg` if no other voices are found. - if (Assets.exists(Paths.voices(this.song.songId, ''))) result.push(Paths.voices(this.song.songId, '$suffix')); + if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix')); } return result; } @@ -442,7 +513,7 @@ class SongDifficulty if (voiceList.length == 0) { - trace('Could not find any voices for song ${this.song.songId}'); + trace('Could not find any voices for song ${this.song.id}'); return result; } diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx deleted file mode 100644 index cef4c98f6..000000000 --- a/source/funkin/play/song/SongData.hx +++ /dev/null @@ -1,1021 +0,0 @@ -package funkin.play.song; - -import funkin.modding.events.ScriptEventDispatcher; -import funkin.modding.events.ScriptEvent; -import flixel.util.typeLimit.OneOfTwo; -import funkin.modding.events.ScriptEvent; -import funkin.modding.events.ScriptEventDispatcher; -import funkin.play.song.ScriptedSong; -import funkin.util.assets.DataAssets; -import haxe.DynamicAccess; -import haxe.Json; -import openfl.utils.Assets; -import thx.semver.Version; -import funkin.util.SerializerUtil; - -/** - * Contains utilities for loading and parsing stage data. - */ -class SongDataParser -{ - /** - * A list containing all the songs available to the game. - */ - static final songCache:Map = new Map(); - - static final DEFAULT_SONG_ID:String = 'UNKNOWN'; - static final SONG_DATA_PATH:String = 'songs/'; - static final MUSIC_DATA_PATH:String = 'music/'; - static final SONG_DATA_SUFFIX:String = '-metadata.json'; - - /** - * Parses and preloads the game's song metadata and scripts when the game starts. - * - * If you want to force song metadata to be reloaded, you can just call this function again. - */ - public static function loadSongCache():Void - { - clearSongCache(); - trace('Loading song cache...'); - - // - // SCRIPTED SONGS - // - var scriptedSongClassNames:Array = ScriptedSong.listScriptClasses(); - trace(' Instantiating ${scriptedSongClassNames.length} scripted songs...'); - for (songCls in scriptedSongClassNames) - { - var song:Song = ScriptedSong.init(songCls, DEFAULT_SONG_ID); - if (song != null) - { - trace(' Loaded scripted song: ${song.songId}'); - songCache.set(song.songId, song); - } - else - { - trace(' Failed to instantiate scripted song class: ${songCls}'); - } - } - - // - // UNSCRIPTED SONGS - // - var songIdList:Array = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX).map(function(songDataPath:String):String { - return songDataPath.split('/')[0]; - }); - var unscriptedSongIds:Array = songIdList.filter(function(songId:String):Bool { - return !songCache.exists(songId); - }); - trace(' Instantiating ${unscriptedSongIds.length} non-scripted songs...'); - for (songId in unscriptedSongIds) - { - try - { - var song:Song = new Song(songId); - if (song != null) - { - trace(' Loaded song data: ${song.songId}'); - songCache.set(song.songId, song); - } - } - catch (e) - { - trace(' An error occurred while loading song data: ${songId}'); - trace(e); - // Assume error was already logged. - continue; - } - } - - trace(' Successfully loaded ${Lambda.count(songCache)} stages.'); - } - - /** - * Retrieves a particular song from the cache. - * @param songId The ID of the song to retrieve. - * @return The song, or null if it was not found. - */ - public static function fetchSong(songId:String):Null - { - if (songCache.exists(songId)) - { - var song:Song = songCache.get(songId); - trace('Successfully fetch song: ${songId}'); - - var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false); - ScriptEventDispatcher.callEvent(song, event); - return song; - } - else - { - trace('Failed to fetch song, not found in cache: ${songId}'); - return null; - } - } - - static function clearSongCache():Void - { - if (songCache != null) - { - songCache.clear(); - } - } - - /** - * A list of all the song IDs available to the game. - * @return The list of song IDs. - */ - public static function listSongIds():Array - { - return songCache.keys().array(); - } - - /** - * Loads the song metadata for a particular song. - * @param songId The ID of the song to load. - * @return The song metadata for each variation, or an empty array if the song was not found. - */ - public static function loadSongMetadata(songId:String):Array - { - var result:Array = []; - - var jsonStr:String = loadSongMetadataFile(songId); - var jsonData:Dynamic = SerializerUtil.fromJSON(jsonStr); - - var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId); - songMetadata = SongValidator.validateSongMetadata(songMetadata, songId); - - if (songMetadata == null) - { - return result; - } - - result.push(songMetadata); - - var variations:Array = songMetadata.playData.songVariations; - - for (variation in variations) - { - var variationJsonStr:String = loadSongMetadataFile(songId, variation); - var variationJsonData:Dynamic = SerializerUtil.fromJSON(variationJsonStr); - var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationJsonData, '${songId}:${variation}'); - variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}:${variation}'); - if (variationSongMetadata != null) - { - variationSongMetadata.variation = variation; - result.push(variationSongMetadata); - } - } - - return result; - } - - static function loadSongMetadataFile(songPath:String, variation:String = ''):String - { - var songMetadataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata'); - - var rawJson:String = Assets.getText(songMetadataFilePath).trim(); - - while (!rawJson.endsWith('}') && rawJson.length > 0) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } - - public static function parseMusicMetadata(musicId:String):SongMetadata - { - var rawJson:String = loadMusicMetadataFile(musicId); - var jsonData:Dynamic = null; - try - { - jsonData = Json.parse(rawJson); - } - catch (e) {} - - var musicMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, musicId); - musicMetadata = SongValidator.validateSongMetadata(musicMetadata, musicId); - - return musicMetadata; - } - - static function loadMusicMetadataFile(musicPath:String, variation:String = ''):String - { - var musicMetadataFilePath:String = (variation != '' || variation == "default") ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json'); - - var rawJson:String = Assets.getText(musicMetadataFilePath).trim(); - - while (!rawJson.endsWith("}")) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } - - public static function parseSongChartData(songId:String, variation:String = ''):SongChartData - { - var rawJson:String = loadSongChartDataFile(songId, variation); - var jsonData:Dynamic = null; - try - { - jsonData = Json.parse(rawJson); - } - catch (e) - { - trace('Failed to parse song chart data: ${songId} (${variation})'); - trace(e); - } - - var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId); - songChartData = SongValidator.validateSongChartData(songChartData, songId); - - if (songChartData == null) - { - trace('Failed to validate song chart data: ${songId}'); - return null; - } - - return songChartData; - } - - static function loadSongChartDataFile(songPath:String, variation:String = ''):String - { - var songChartDataFilePath:String = (variation != '' && variation != 'default') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart'); - - var rawJson:String = Assets.getText(songChartDataFilePath).trim(); - - while (!rawJson.endsWith('}') && rawJson.length > 0) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } -} - -typedef RawSongMetadata = -{ - /** - * A semantic versioning string for the song data format. - * - */ - var version:Version; - - var songName:String; - var artist:String; - var timeFormat:SongTimeFormat; - var divisions:Null; // Optional field - var timeChanges:Array; - var looped:Bool; - var playData:SongPlayData; - var generatedBy:String; - - /** - * Defaults to `default` or `''`. Populated later. - */ - var variation:String; -}; - -@:forward -abstract SongMetadata(RawSongMetadata) -{ - public function new(songName:String, artist:String, variation:String = 'default') - { - this = - { - version: SongMigrator.CHART_VERSION, - songName: songName, - artist: artist, - timeFormat: 'ms', - divisions: null, - timeChanges: [new SongTimeChange(-1, 0, 100, 4, 4, [4, 4, 4, 4])], - looped: false, - playData: - { - songVariations: [], - difficulties: ['normal'], - - playableChars: - { - bf: new SongPlayableChar('gf', 'dad'), - }, - - stage: 'mainStage', - noteSkin: 'Normal' - }, - generatedBy: SongValidator.DEFAULT_GENERATEDBY, - - // Variation ID. - variation: variation - }; - } - - public function clone(?newVariation:String = null):SongMetadata - { - var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); - result.version = this.version; - result.timeFormat = this.timeFormat; - result.divisions = this.divisions; - result.timeChanges = this.timeChanges; - result.looped = this.looped; - result.playData = this.playData; - result.generatedBy = this.generatedBy; - - return result; - } -} - -typedef SongPlayData = -{ - var songVariations:Array; - var difficulties:Array; - - /** - * Keys are the player characters and the values give info on what opponent/GF/inst to use. - */ - var playableChars:DynamicAccess; - - var stage:String; - var noteSkin:String; -} - -typedef RawSongPlayableChar = -{ - var g:String; - var o:String; - var i:String; -} - -typedef RawSongNoteData = -{ - /** - * The timestamp of the note. The timestamp is in the format of the song's time format. - */ - var t:Float; - - /** - * Data for the note. Represents the index on the strumline. - * 0 = left, 1 = down, 2 = up, 3 = right - * `floor(direction / strumlineSize)` specifies which strumline the note is on. - * 0 = player, 1 = opponent, etc. - */ - var d:Int; - - /** - * Length of the note, if applicable. - * Defaults to 0 for single notes. - */ - var l:Float; - - /** - * The kind of the note. - * This can allow the note to include information used for custom behavior. - * Defaults to blank or `"normal"`. - */ - var k:String; -} - -abstract SongNoteData(RawSongNoteData) -{ - public function new(time:Float, data:Int, length:Float = 0, kind:String = '') - { - this = - { - t: time, - d: data, - l: length, - k: kind - }; - } - - /** - * The timestamp of the note, in milliseconds. - */ - public var time(get, set):Float; - - function get_time():Float - { - return this.t; - } - - function set_time(value:Float):Float - { - return this.t = value; - } - - /** - * The timestamp of the note, in steps. - */ - public var stepTime(get, never):Float; - - function get_stepTime():Float - { - return Conductor.getTimeInSteps(abstract.time); - } - - /** - * The raw data for the note. - */ - public var data(get, set):Int; - - function get_data():Int - { - return this.d; - } - - function set_data(value:Int):Int - { - return this.d = value; - } - - /** - * The direction of the note, if applicable. - * Strips the strumline index from the data. - * - * 0 = left, 1 = down, 2 = up, 3 = right - */ - public inline function getDirection(strumlineSize:Int = 4):Int - { - return abstract.data % strumlineSize; - } - - public function getDirectionName(strumlineSize:Int = 4):String - { - switch (abstract.data % strumlineSize) - { - case 0: - return 'Left'; - case 1: - return 'Down'; - case 2: - return 'Up'; - case 3: - return 'Right'; - default: - return 'Unknown'; - } - } - - /** - * The strumline index of the note, if applicable. - * Strips the direction from the data. - * - * 0 = player, 1 = opponent, etc. - */ - public inline function getStrumlineIndex(strumlineSize:Int = 4):Int - { - return Math.floor(abstract.data / strumlineSize); - } - - /** - * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side). - * TODO: The name of this function is a little misleading; what about mines? - * @param strumlineSize Defaults to 4. - * @return True if it's Boyfriend's note. - */ - public inline function getMustHitNote(strumlineSize:Int = 4):Bool - { - return getStrumlineIndex(strumlineSize) == 0; - } - - /** - * If this is a hold note, this is the length of the hold note in milliseconds. - * @default 0 (not a hold note) - */ - public var length(get, set):Float; - - function get_length():Float - { - return this.l; - } - - function set_length(value:Float):Float - { - return this.l = value; - } - - /** - * If this is a hold note, this is the length of the hold note in steps. - * @default 0 (not a hold note) - */ - public var stepLength(get, set):Float; - - function get_stepLength():Float - { - return Conductor.getTimeInSteps(abstract.time + abstract.length) - abstract.stepTime; - } - - function set_stepLength(value:Float):Float - { - return abstract.length = Conductor.getStepTimeInMs(value) - abstract.time; - } - - public var isHoldNote(get, never):Bool; - - public function get_isHoldNote():Bool - { - return this.l > 0; - } - - public var kind(get, set):String; - - function get_kind():String - { - if (this.k == null || this.k == '') return 'normal'; - - return this.k; - } - - function set_kind(value:String):String - { - if (value == 'normal' || value == '') value = null; - return this.k = value; - } - - @:op(A == B) - public function op_equals(other:SongNoteData):Bool - { - if (abstract.kind == '') - { - if (other.kind != '' && other.kind != 'normal') return false; - } - else - { - if (other.kind == '' || other.kind != abstract.kind) return false; - } - - return abstract.time == other.time && abstract.data == other.data && abstract.length == other.length; - } - - @:op(A != B) - public function op_notEquals(other:SongNoteData):Bool - { - if (abstract.kind == '') - { - if (other.kind != '' && other.kind != 'normal') return true; - } - else - { - if (other.kind == '' || other.kind != abstract.kind) return true; - } - - return abstract.time != other.time || abstract.data != other.data || abstract.length != other.length; - } - - @:op(A > B) - public function op_greaterThan(other:SongNoteData):Bool - { - return abstract.time > other.time; - } - - @:op(A < B) - public function op_lessThan(other:SongNoteData):Bool - { - return this.t < other.time; - } - - @:op(A >= B) - public function op_greaterThanOrEquals(other:SongNoteData):Bool - { - return this.t >= other.time; - } - - @:op(A <= B) - public function op_lessThanOrEquals(other:SongNoteData):Bool - { - return this.t <= other.time; - } -} - -typedef RawSongEventData = -{ - /** - * The timestamp of the event. The timestamp is in the format of the song's time format. - */ - var t:Float; - - /** - * The kind of the event. - * Examples include "FocusCamera" and "PlayAnimation" - * Custom events can be added by scripts with the `ScriptedSongEvent` class. - */ - var e:String; - - /** - * The data for the event. - * This can allow the event to include information used for custom behavior. - * Data type depends on the event kind. It can be anything that's JSON serializable. - */ - var v:DynamicAccess; - - /** - * Whether this event has been activated. - * This is only used internally by the game. It should not be serialized. - */ - @:optional var a:Bool; -} - -abstract SongEventData(RawSongEventData) -{ - public function new(time:Float, event:String, value:Dynamic = null) - { - this = - { - t: time, - e: event, - v: value, - a: false - }; - } - - public var time(get, set):Float; - - function get_time():Float - { - return this.t; - } - - function set_time(value:Float):Float - { - return this.t = value; - } - - public var stepTime(get, never):Float; - - function get_stepTime():Float - { - return Conductor.getTimeInSteps(abstract.time); - } - - public var event(get, set):String; - - function get_event():String - { - return this.e; - } - - function set_event(value:String):String - { - return this.e = value; - } - - public var value(get, set):Dynamic; - - function get_value():Dynamic - { - return this.v; - } - - function set_value(value:Dynamic):Dynamic - { - return this.v = value; - } - - public var activated(get, set):Bool; - - function get_activated():Bool - { - return this.a; - } - - function set_activated(value:Bool):Bool - { - return this.a = value; - } - - public inline function getDynamic(key:String):Null - { - return this.v.get(key); - } - - public inline function getBool(key:String):Null - { - return cast this.v.get(key); - } - - public inline function getInt(key:String):Null - { - return cast this.v.get(key); - } - - public inline function getFloat(key:String):Null - { - return cast this.v.get(key); - } - - public inline function getString(key:String):String - { - return cast this.v.get(key); - } - - public inline function getArray(key:String):Array - { - return cast this.v.get(key); - } - - public inline function getBoolArray(key:String):Array - { - return cast this.v.get(key); - } - - @:op(A == B) - public function op_equals(other:SongEventData):Bool - { - return this.t == other.time && this.e == other.event && this.v == other.value; - } - - @:op(A != B) - public function op_notEquals(other:SongEventData):Bool - { - return this.t != other.time || this.e != other.event || this.v != other.value; - } - - @:op(A > B) - public function op_greaterThan(other:SongEventData):Bool - { - return this.t > other.time; - } - - @:op(A < B) - public function op_lessThan(other:SongEventData):Bool - { - return this.t < other.time; - } - - @:op(A >= B) - public function op_greaterThanOrEquals(other:SongEventData):Bool - { - return this.t >= other.time; - } - - @:op(A <= B) - public function op_lessThanOrEquals(other:SongEventData):Bool - { - return this.t <= other.time; - } -} - -abstract SongPlayableChar(RawSongPlayableChar) -{ - public function new(girlfriend:String, opponent:String, inst:String = '') - { - this = - { - g: girlfriend, - o: opponent, - i: inst - }; - } - - public var girlfriend(get, set):String; - - function get_girlfriend():String - { - return this.g; - } - - function set_girlfriend(value:String):String - { - return this.g = value; - } - - public var opponent(get, set):String; - - function get_opponent():String - { - return this.o; - } - - function set_opponent(value:String):String - { - return this.o = value; - } - - public var inst(get, set):String; - - function get_inst():String - { - return this.i; - } - - function set_inst(value:String):String - { - return this.i = value; - } -} - -typedef RawSongChartData = -{ - var version:Version; - - var scrollSpeed:DynamicAccess; - var events:Array; - var notes:DynamicAccess>; - var generatedBy:String; -}; - -@:forward -abstract SongChartData(RawSongChartData) -{ - public function new(scrollSpeed:Float, events:Array, notes:Array) - { - this = - { - version: SongMigrator.CHART_VERSION, - - events: events, - notes: - { - normal: notes - }, - scrollSpeed: - { - normal: scrollSpeed - }, - generatedBy: SongValidator.DEFAULT_GENERATEDBY - } - } - - public function getScrollSpeed(diff:String = 'default'):Float - { - var result:Float = this.scrollSpeed.get(diff); - - if (result == 0.0 && diff != 'default') return getScrollSpeed('default'); - - return (result == 0.0) ? 1.0 : result; - } - - public function setScrollSpeed(value:Float, diff:String = 'default'):Float - { - return this.scrollSpeed.set(diff, value); - } - - public function getNotes(diff:String):Array - { - var result:Array = this.notes.get(diff); - - if (result == null && diff != 'normal') return getNotes('normal'); - - return (result == null) ? [] : result; - } - - public function setNotes(value:Array, diff:String):Array - { - return this.notes.set(diff, value); - } - - public function getEvents():Array - { - return this.events; - } - - public function setEvents(value:Array):Array - { - return this.events = value; - } -} - -typedef RawSongTimeChange = -{ - /** - * Timestamp in specified `timeFormat`. - */ - var t:Float; - - /** - * Time in beats (int). The game will calculate further beat values based on this one, - * so it can do it in a simple linear fashion. - */ - var b:Null; - - /** - * Quarter notes per minute (float). Cannot be empty in the first element of the list, - * but otherwise it's optional, and defaults to the value of the previous element. - */ - var bpm:Float; - - /** - * Time signature numerator (int). Optional, defaults to 4. - */ - var n:Int; - - /** - * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. - */ - var d:Int; - - /** - * Beat tuplets (Array or int). This defines how many steps each beat is divided into. - * It can either be an array of length `n` (see above) or a single integer number. - * Optional, defaults to `[4]`. - */ - var bt:OneOfTwo>; -} - -/** - * Add aliases to the minimalized property names of the typedef, - * to improve readability. - */ -abstract SongTimeChange(RawSongTimeChange) from RawSongTimeChange -{ - public function new(timeStamp:Float, ?beatTime:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array) - { - this = - { - t: timeStamp, - b: beatTime, - bpm: bpm, - n: timeSignatureNum, - d: timeSignatureDen, - bt: beatTuplets, - } - } - - public var timeStamp(get, set):Float; - - function get_timeStamp():Float - { - return this.t; - } - - function set_timeStamp(value:Float):Float - { - return this.t = value; - } - - public var beatTime(get, set):Null; - - public function get_beatTime():Null - { - return this.b; - } - - public function set_beatTime(value:Null):Null - { - return this.b = value; - } - - public var bpm(get, set):Float; - - function get_bpm():Float - { - return this.bpm; - } - - function set_bpm(value:Float):Float - { - return this.bpm = value; - } - - public var timeSignatureNum(get, set):Int; - - function get_timeSignatureNum():Int - { - return this.n; - } - - function set_timeSignatureNum(value:Int):Int - { - return this.n = value; - } - - public var timeSignatureDen(get, set):Int; - - function get_timeSignatureDen():Int - { - return this.d; - } - - function set_timeSignatureDen(value:Int):Int - { - return this.d = value; - } - - public var beatTuplets(get, set):Array; - - function get_beatTuplets():Array - { - if (Std.isOfType(this.bt, Int)) - { - return [this.bt]; - } - else - { - return this.bt; - } - } - - function set_beatTuplets(value:Array):Array - { - return this.bt = value; - } -} - -enum abstract SongTimeFormat(String) from String to String -{ - var TICKS = 'ticks'; - var FLOAT = 'float'; - var MILLISECONDS = 'ms'; -} diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx index bb8718bb7..48ae50037 100644 --- a/source/funkin/play/song/SongMigrator.hx +++ b/source/funkin/play/song/SongMigrator.hx @@ -1,11 +1,11 @@ package funkin.play.song; import funkin.play.song.formats.FNFLegacy; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongMetadata; -import funkin.play.song.SongData.SongNoteData; -import funkin.play.song.SongData.SongPlayableChar; +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 @@ -176,7 +176,7 @@ class SongMigrator songMetadata.playData.songVariations = []; // Set the song's song variations. - songMetadata.playData.playableChars = {}; + songMetadata.playData.playableChars = []; try { Reflect.setField(songMetadata.playData.playableChars, songData.song.player1, new SongPlayableChar('', songData.song.player2)); @@ -203,7 +203,7 @@ class SongMigrator var songData:FNFLegacy = cast jsonData; - var songChartData:SongChartData = new SongChartData(1.0, [], []); + 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)); diff --git a/source/funkin/play/song/SongSerializer.hx b/source/funkin/play/song/SongSerializer.hx index a08b722da..a0a468c5b 100644 --- a/source/funkin/play/song/SongSerializer.hx +++ b/source/funkin/play/song/SongSerializer.hx @@ -1,7 +1,7 @@ package funkin.play.song; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; import funkin.util.SerializerUtil; import lime.utils.Bytes; import openfl.events.Event; diff --git a/source/funkin/play/song/SongValidator.hx b/source/funkin/play/song/SongValidator.hx index 11cc758b9..e33ddd87c 100644 --- a/source/funkin/play/song/SongValidator.hx +++ b/source/funkin/play/song/SongValidator.hx @@ -1,10 +1,11 @@ package funkin.play.song; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongMetadata; -import funkin.play.song.SongData.SongPlayData; -import funkin.play.song.SongData.SongTimeChange; -import funkin.play.song.SongData.SongTimeFormat; +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, @@ -59,7 +60,7 @@ class SongValidator } if (input.generatedBy == null) { - input.generatedBy = DEFAULT_GENERATEDBY; + input.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; } input.timeChanges = validateTimeChanges(input.timeChanges, songId); diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx index f0ecb573b..79f58a098 100644 --- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx +++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx @@ -1,8 +1,8 @@ package funkin.ui.debug.charting; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongNoteData; -import funkin.play.song.SongDataUtils; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; using Lambda; diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx index b7a51600a..1c73413dc 100644 --- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx @@ -3,8 +3,8 @@ package funkin.ui.debug.charting; import funkin.play.character.CharacterData; import funkin.util.Constants; import funkin.util.SerializerUtil; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; import flixel.util.FlxTimer; import funkin.util.SortUtil; import funkin.input.Cursor; @@ -13,9 +13,9 @@ import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.song.Song; import funkin.play.song.SongMigrator; import funkin.play.song.SongValidator; -import funkin.play.song.SongData.SongDataParser; -import funkin.play.song.SongData.SongPlayableChar; -import funkin.play.song.SongData.SongTimeChange; +import funkin.data.song.SongRegistry; +import funkin.data.song.SongData.SongPlayableChar; +import funkin.data.song.SongData.SongTimeChange; import funkin.util.FileUtil; import haxe.io.Path; import haxe.ui.components.Button; @@ -112,23 +112,17 @@ class ChartEditorDialogHandler var splashTemplateContainer:Null = dialog.findComponent('splashTemplateContainer', VBox); if (splashTemplateContainer == null) throw 'Could not locate splashTemplateContainer in Welcome dialog'; - var songList:Array = SongDataParser.listSongIds(); + var songList:Array = SongRegistry.instance.listEntryIds(); songList.sort(SortUtil.alphabetically); for (targetSongId in songList) { - var songData:Null = SongDataParser.fetchSong(targetSongId); - + var songData:Null = SongRegistry.instance.fetchEntry(targetSongId); if (songData == null) continue; - var diffNormal:Null = songData.getDifficulty('normal'); - var songName:Null = diffNormal?.songName; - if (songName == null) - { - var diffDefault:Null = songData.getDifficulty(); - songName = diffDefault?.songName; - } - if (songName == null) + var songName:Null = songData.getDifficulty('normal')?.songName; + if (songName == null) songName = songData.getDifficulty()?.songName; + if (songName == null) // Still null? { trace('[WARN] Could not fetch song name for ${targetSongId}'); continue; @@ -508,9 +502,9 @@ class ChartEditorDialogHandler if (dialogNoteSkin == null) throw 'Could not locate dialogNoteSkin DropDown in Song Metadata dialog'; dialogNoteSkin.onChange = function(event:UIEvent) { if (event.data.id == null) return; - state.currentSongMetadata.playData.noteSkin = event.data.id; + state.currentSongNoteSkin = event.data.id; }; - state.currentSongMetadata.playData.noteSkin = 'funkin'; + state.currentSongNoteSkin = 'funkin'; var dialogBPM:Null = dialog.findComponent('dialogBPM', NumberStepper); if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Song Metadata dialog'; @@ -520,7 +514,7 @@ class ChartEditorDialogHandler var timeChanges:Array = state.currentSongMetadata.timeChanges; if (timeChanges == null || timeChanges.length == 0) { - timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])]; + timeChanges = [new SongTimeChange(0, event.value)]; } else { @@ -543,7 +537,7 @@ class ChartEditorDialogHandler }; // Empty the character list. - state.currentSongMetadata.playData.playableChars = {}; + state.currentSongMetadata.playData.playableChars = []; // Add at least one character group with no Remove button. dialogCharGrid.addComponent(buildCharGroup(state, 'bf')); @@ -558,7 +552,8 @@ class ChartEditorDialogHandler { var groupKey:String = key; - var getCharData:Void->SongPlayableChar = function() { + var getCharData:Void->Null = function():Null { + if (state.currentSongMetadata.playData == null) return null; if (groupKey == null) groupKey = 'newChar${state.currentSongMetadata.playData.playableChars.keys().count()}'; var result = state.currentSongMetadata.playData.playableChars.get(groupKey); @@ -570,46 +565,53 @@ class ChartEditorDialogHandler return result; } - var moveCharGroup:String->Void = function(target:String) { - var charData = getCharData(); + var moveCharGroup:String->Void = function(target:String):Void { + var charData:Null = getCharData(); + if (charData == null) return; + + if (state.currentSongMetadata.playData.playableChars == null) return; state.currentSongMetadata.playData.playableChars.remove(groupKey); state.currentSongMetadata.playData.playableChars.set(target, charData); groupKey = target; } - var removeGroup:Void->Void = function() { + var removeGroup:Void->Void = function():Void { + if (state?.currentSongMetadata?.playData?.playableChars == null) return; state.currentSongMetadata.playData.playableChars.remove(groupKey); if (removeFunc != null) removeFunc(); } - var charData:SongPlayableChar = getCharData(); + var charData:Null = getCharData(); var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT); var charGroupPlayer:Null = charGroup.findComponent('charGroupPlayer', DropDown); if (charGroupPlayer == null) throw 'Could not locate charGroupPlayer DropDown in Song Metadata dialog'; - charGroupPlayer.onChange = function(event:UIEvent) { + charGroupPlayer.onChange = function(event:UIEvent):Void { + if (charData != null) return; charGroup.text = event.data.text; moveCharGroup(event.data.id); }; var charGroupOpponent:Null = charGroup.findComponent('charGroupOpponent', DropDown); if (charGroupOpponent == null) throw 'Could not locate charGroupOpponent DropDown in Song Metadata dialog'; - charGroupOpponent.onChange = function(event:UIEvent) { + charGroupOpponent.onChange = function(event:UIEvent):Void { + if (charData == null) return; charData.opponent = event.data.id; }; - charGroupOpponent.value = getCharData().opponent; + charGroupOpponent.value = charData.opponent; var charGroupGirlfriend:Null = charGroup.findComponent('charGroupGirlfriend', DropDown); if (charGroupGirlfriend == null) throw 'Could not locate charGroupGirlfriend DropDown in Song Metadata dialog'; - charGroupGirlfriend.onChange = function(event:UIEvent) { + charGroupGirlfriend.onChange = function(event:UIEvent):Void { + if (charData == null) return; charData.girlfriend = event.data.id; }; - charGroupGirlfriend.value = getCharData().girlfriend; + charGroupGirlfriend.value = charData.girlfriend; var charGroupRemove:Null