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 f06b295e4..47460facf 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..b79ae0fc4 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, @@ -290,7 +290,7 @@ class Conductor if (timeChanges.length > 0) { - trace('Done mapping time changes: ${timeChanges}' + timeChanges); + trace('Done mapping time changes: ${timeChanges}'); } // Update currentStepTime 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<T:(IRegistryEntry<J> & Constructible<EntryConstructo public function fetchEntryVersion(id:String):Null<thx.semver.Version> { - 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<T:(IRegistryEntry<J> & Constructible<EntryConstructo trace('[' + registryId + '] ' + message); } - function loadEntryFile(id:String):String + function loadEntryFile(id:String):JsonFile { var entryFilePath:String = Paths.json('${dataFilePath}/${id}'); var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); - return rawJson; + return { + fileName: entryFilePath, + contents: rawJson + }; } function clearEntries():Void @@ -188,7 +194,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo } else { - throw '[${registryId}] Entry ${id} does not support migration.'; + throw '[${registryId}] Entry ${id} does not support migration to version ${versionRule}.'; } // Example: @@ -219,4 +225,85 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo * @param clsName */ abstract function createScriptedEntry(clsName:String):Null<T>; + + function printErrors(errors:Array<Error>, id:String = ''):Void + { + trace('[${registryId}] Failed to parse entry data: ${id}'); + + for (error in errors) + printError(error); + } + + function printError(error:Error):Void + { + switch (error) + { + case IncorrectType(vari, expected, pos): + trace(' Expected field "$vari" to be of type "$expected".'); + printPos(pos); + case IncorrectEnumValue(value, expected, pos): + trace(' Invalid enum value (expected "$expected", got "$value")'); + printPos(pos); + case InvalidEnumConstructor(value, expected, pos): + trace(' Invalid enum constructor (epxected "$expected", got "$value")'); + printPos(pos); + case UninitializedVariable(vari, pos): + trace(' Uninitialized variable "$vari"'); + printPos(pos); + case UnknownVariable(vari, pos): + trace(' Unknown variable "$vari"'); + printPos(pos); + case ParserError(message, pos): + trace(' Parsing error: ${message}'); + printPos(pos); + case CustomFunctionException(e, pos): + if (Std.isOfType(e, String)) + { + trace(' ${e}'); + } + else + { + printUnknownError(e); + } + printPos(pos); + default: + printUnknownError(error); + } + } + + function printUnknownError(e:Dynamic):Void + { + switch (Type.typeof(e)) + { + case TClass(c): + trace(' [${Type.getClassName(c)}] ${e.toString()}'); + case TEnum(c): + trace(' [${Type.getEnumName(c)}] ${e.toString()}'); + default: + trace(' [${Type.typeof(e)}] ${e.toString()}'); + } + } + + /** + * TODO: Figure out the nicest way to print this. + * Maybe look up how other JSON parsers format their errors? + * @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx + */ + function printPos(pos:Position):Void + { + if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number) + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}'); + } + else + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}'); + } + } } + +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..f6b5dd659 --- /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<Dynamic> + { + return switch (json.value) + { + case JString(s): s; + case JNumber(n): Std.parseInt(n); + case JBool(b): b; + case JNull: null; + case JObject(fields): jsonFieldsToDynamicObject(fields); + case JArray(values): jsonArrayToDynamicArray(values); + } + } + + /** + * 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<JObjectField>):Dynamic + { + var result:Dynamic = {}; + for (field in fields) + { + Reflect.setField(result, field.name, jsonToDynamic(field.value)); + } + return result; + } + + /** + * Array of JSON elements `[Json, Json, Json]` to a Dynamic array `[String, Object, Int, Array]` + * @param jsons + * @return Array<Dynamic> + */ + static function jsonArrayToDynamicArray(jsons:Array<Json>):Array<Null<Dynamic>> + { + 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<T> // Can't make an interface field private I guess. public final _data:T; - public function _fetchData(id:String):Null<T>; + // Can't make a static field required by an interface I guess. + // private static function _fetchData(id:String):Null<T>; } 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<Level, LevelData> // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<LevelData>(); - 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<NoteStyle, NoteStyleData> */ public function parseEntryData(id:String):Null<NoteStyleData> { - 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<NoteStyleData>(); - 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..6dc6f55e7 --- /dev/null +++ b/source/funkin/data/song/SongData.hx @@ -0,0 +1,680 @@ +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<Int>; // 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<SongTimeChange>; + + /** + * 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; + } + + public function toString():String + { + return 'SongMetadata(${this.songName} by ${this.artist}, variation ${this.variation})'; + } +} + +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<SongTimeChange> = [DEFAULT_SONGTIMECHANGE]; + + static final DEFAULT_BEAT_TUPLETS:Array<Int> = [4, 4, 4, 4]; + static final DEFAULT_BEAT_TIME:Null<Float> = 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<Float>; + + /** + * 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<int> 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<Int>; + + public function new(timeStamp:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, ?beatTime:Float, ?beatTuplets:Array<Int>) + { + 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; + } + + public function toString():String + { + return 'SongTimeChange(${this.timeStamp}ms,${this.bpm}bpm)'; + } +} + +/** + * 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<Int>; // 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<SongTimeChange>; + + /** + * 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; + } + + public function toString():String + { + return 'SongMusicData(${this.songName} by ${this.artist}, variation ${this.variation})'; + } +} + +typedef SongPlayData = +{ + public var songVariations:Array<String>; + public var difficulties:Array<String>; + + /** + * Keys are the player characters and the values give info on what opponent/GF/inst to use. + */ + public var playableChars:Map<String, SongPlayableChar>; + + 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; + } + + public function toString():String + { + return 'SongPlayableChar(${this.girlfriend}, ${this.opponent}, ${this.inst})'; + } +} + +class SongChartData +{ + @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION) + public var version:Version; + + public var scrollSpeed:Map<String, Float>; + public var events:Array<SongEventData>; + public var notes:Map<String, Array<SongNoteData>>; + + @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + public function new(scrollSpeed:Map<String, Float>, events:Array<SongEventData>, notes:Map<String, Array<SongNoteData>>) + { + 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<SongNoteData> + { + var result:Array<SongNoteData> = this.notes.get(diff); + + if (result == null && diff != 'normal') return getNotes('normal'); + + return (result == null) ? [] : result; + } + + public function setNotes(value:Array<SongNoteData>, diff:String):Array<SongNoteData> + { + this.notes.set(diff, value); + return value; + } + + public function getEvents():Array<SongEventData> + { + return this.events; + } + + public function setEvents(value:Array<SongEventData>):Array<SongEventData> + { + 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<Dynamic> + { + return value == null ? null : Reflect.field(value, key); + } + + public inline function getBool(key:String):Null<Bool> + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getInt(key:String):Null<Int> + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getFloat(key:String):Null<Float> + { + 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<Dynamic> + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getBoolArray(key:String):Array<Bool> + { + 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; + } + + public function toString():String + { + return 'SongEventData(${this.time}ms, ${this.event}: ${this.value})'; + } +} + +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; + } + + public function toString():String + { + return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}' + + (this.kind != '' ? ' [kind: ${this.kind}])' : ')'); + } +} 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..9bc1278c8 --- /dev/null +++ b/source/funkin/data/song/SongRegistry.hx @@ -0,0 +1,271 @@ +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<Song, SongMetadata> +{ + /** + * 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<String> = 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<String> = DataAssets.listDataFilesInPath('songs/', '-metadata.json').map(function(songDataPath:String):String { + return songDataPath.split('/')[0]; + }); + var unscriptedEntryIds:Array<String> = 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<SongMetadata> + { + return parseEntryMetadata(id); + } + + public function parseEntryMetadata(id:String, variation:String = ""):Null<SongMetadata> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + + var parser = new json2object.JsonParser<SongMetadata>(); + 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<SongMetadata> + { + // If a version rule is not specified, do not check against it. + if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE)) + { + return 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<SongMusicData> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + + var parser = new json2object.JsonParser<SongMusicData>(); + 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<SongChartData> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser<SongChartData>(); + + 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<SongChartData> + { + // If a version rule is not specified, do not check against it. + if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE)) + { + return 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<String> + { + return ScriptedSong.listScriptClasses(); + } + + function loadEntryMetadataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile> + { + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-metadata'); + if (!openfl.Assets.exists(entryFilePath)) return null; + var rawJson:Null<String> = openfl.Assets.getText(entryFilePath); + if (rawJson == null) return null; + rawJson = rawJson.trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + function loadMusicDataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile> + { + var entryFilePath:String = Paths.file('music/$id/$id${variation == '' ? '' : '-$variation'}-metadata.json'); + if (!openfl.Assets.exists(entryFilePath)) return null; + var rawJson:String = openfl.Assets.getText(entryFilePath); + if (rawJson == null) return null; + rawJson = rawJson.trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + function loadEntryChartFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile> + { + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-chart'); + if (!openfl.Assets.exists(entryFilePath)) return null; + var rawJson:String = openfl.Assets.getText(entryFilePath); + if (rawJson == null) return null; + rawJson = rawJson.trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null<thx.semver.Version> + { + var entryStr:Null<String> = loadEntryMetadataFile(id, variation)?.contents; + var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + return entryVersion; + } + + public function fetchEntryChartVersion(id:String, variation:String = ''):Null<thx.semver.Version> + { + var entryStr:Null<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<String> + { + 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<String> + { + 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<NoteStyleData> 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<NoteStyleData> 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<NoteStyleData> return 'NoteStyle($id)'; } - public function _fetchData(id:String):Null<NoteStyleData> + static function _fetchData(id:String):Null<NoteStyleData> { 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..e32eb8186 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<SongMetadata> { - 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<Int> = null; + public static final DEFAULT_LOOPED:Bool = false; + public static final DEFAULT_STAGE:String = "mainStage"; + public static final DEFAULT_SCROLLSPEED:Float = 1.0; + + public 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<SongMetadata>; final _metadata:Array<SongMetadata>; @@ -39,33 +58,56 @@ class Song implements IPlayStateScriptedClass var difficultyIds:Array<String>; + 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<String, SongDifficulty>(); - 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<SongMetadata>, variations:Array<String>, charts:Map<String, SongChartData>, 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<String, SongPlayableChar>(); + if (metadata.playData.playableChars == null) continue; for (charId in metadata.playData.playableChars.keys()) { - var char = metadata.playData.playableChars.get(charId); - + var char:Null<SongPlayableChar> = 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<thx.semver.Version> = SongRegistry.instance.fetchEntryChartVersion(id, variation); + if (version == null) continue; + var chart:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataWithMigration(id, variation, version); + 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<SongDifficulty> { 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<SongMetadata> + { + trace('Fetching song metadata for $id'); + var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id); + if (version == null) return null; + return SongRegistry.instance.parseEntryMetadataWithMigration(id, '', version); + } + + function fetchVariationMetadata(id:String):Array<SongMetadata> + { + var result:Array<SongMetadata> = []; + for (vari in variations) + { + var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id, vari); + if (version == null) continue; + var meta:Null<SongMetadata> = 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<Int> = 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<SongTimeChange> = []; @@ -351,18 +422,18 @@ class SongDifficulty var currentPlayer:Null<SongPlayableChar> = 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<String> = []; @@ -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<String, Song> = new Map<String, Song>(); - - 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<String> = 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<String> = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX).map(function(songDataPath:String):String { - return songDataPath.split('/')[0]; - }); - var unscriptedSongIds:Array<String> = 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<Song> - { - 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<String> - { - 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<SongMetadata> - { - var result:Array<SongMetadata> = []; - - 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<String> = 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<Int>; // Optional field - var timeChanges:Array<SongTimeChange>; - 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<String>; - var difficulties:Array<String>; - - /** - * Keys are the player characters and the values give info on what opponent/GF/inst to use. - */ - var playableChars:DynamicAccess<SongPlayableChar>; - - 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<Dynamic>; - - /** - * 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<Dynamic> - { - return this.v.get(key); - } - - public inline function getBool(key:String):Null<Bool> - { - return cast this.v.get(key); - } - - public inline function getInt(key:String):Null<Int> - { - return cast this.v.get(key); - } - - public inline function getFloat(key:String):Null<Float> - { - 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<Dynamic> - { - return cast this.v.get(key); - } - - public inline function getBoolArray(key:String):Array<Bool> - { - 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<Float>; - var events:Array<SongEventData>; - var notes:DynamicAccess<Array<SongNoteData>>; - var generatedBy:String; -}; - -@:forward -abstract SongChartData(RawSongChartData) -{ - public function new(scrollSpeed:Float, events:Array<SongEventData>, notes:Array<SongNoteData>) - { - 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<SongNoteData> - { - var result:Array<SongNoteData> = this.notes.get(diff); - - if (result == null && diff != 'normal') return getNotes('normal'); - - return (result == null) ? [] : result; - } - - public function setNotes(value:Array<SongNoteData>, diff:String):Array<SongNoteData> - { - return this.notes.set(diff, value); - } - - public function getEvents():Array<SongEventData> - { - return this.events; - } - - public function setEvents(value:Array<SongEventData>):Array<SongEventData> - { - 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<Float>; - - /** - * 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<int> 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<Int, Array<Int>>; -} - -/** - * 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<Int>) - { - 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<Float>; - - public function get_beatTime():Null<Float> - { - return this.b; - } - - public function set_beatTime(value:Null<Float>):Null<Float> - { - 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<Int>; - - function get_beatTuplets():Array<Int> - { - if (Std.isOfType(this.bt, Int)) - { - return [this.bt]; - } - else - { - return this.bt; - } - } - - function set_beatTuplets(value:Array<Int>):Array<Int> - { - 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 f33d9bbe9..43393fa4e 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 { songMetadata.playData.playableChars.set(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 59bee0d74..f24169810 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.ui.haxeui.components.FunkinLink; import funkin.util.SortUtil; @@ -14,9 +14,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; @@ -113,23 +113,17 @@ class ChartEditorDialogHandler var splashTemplateContainer:Null<VBox> = dialog.findComponent('splashTemplateContainer', VBox); if (splashTemplateContainer == null) throw 'Could not locate splashTemplateContainer in Welcome dialog'; - var songList:Array<String> = SongDataParser.listSongIds(); + var songList:Array<String> = SongRegistry.instance.listEntryIds(); songList.sort(SortUtil.alphabetically); for (targetSongId in songList) { - var songData:Null<Song> = SongDataParser.fetchSong(targetSongId); - + var songData:Null<Song> = SongRegistry.instance.fetchEntry(targetSongId); if (songData == null) continue; - var diffNormal:Null<SongDifficulty> = songData.getDifficulty('normal'); - var songName:Null<String> = diffNormal?.songName; - if (songName == null) - { - var diffDefault:Null<SongDifficulty> = songData.getDifficulty(); - songName = diffDefault?.songName; - } - if (songName == null) + var songName:Null<String> = 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; @@ -509,9 +503,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<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper); if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Song Metadata dialog'; @@ -521,7 +515,7 @@ class ChartEditorDialogHandler var timeChanges:Array<SongTimeChange> = 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 { @@ -544,7 +538,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')); @@ -559,7 +553,8 @@ class ChartEditorDialogHandler { var groupKey:String = key; - var getCharData:Void->SongPlayableChar = function() { + var getCharData:Void->Null<SongPlayableChar> = function():Null<SongPlayableChar> { + if (state.currentSongMetadata.playData == null) return null; if (groupKey == null) groupKey = 'newChar${state.currentSongMetadata.playData.playableChars.keys().count()}'; var result = state.currentSongMetadata.playData.playableChars.get(groupKey); @@ -571,46 +566,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<SongPlayableChar> = getCharData(); + if (charData == null) return; + + if (state.currentSongMetadata.playData.playableChars == null) return; state.currentSongMetadata.playData.playableChars.remove(groupKey); state.currentSongMetadata.playData.playableChars.set(target, charData); groupKey = target; } - var removeGroup:Void->Void = function() { + 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<SongPlayableChar> = getCharData(); var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT); var charGroupPlayer:Null<DropDown> = charGroup.findComponent('charGroupPlayer', DropDown); if (charGroupPlayer == null) throw 'Could not locate charGroupPlayer DropDown in Song Metadata dialog'; - charGroupPlayer.onChange = function(event:UIEvent) { + charGroupPlayer.onChange = function(event:UIEvent):Void { + if (charData != null) return; charGroup.text = event.data.text; moveCharGroup(event.data.id); }; var charGroupOpponent:Null<DropDown> = charGroup.findComponent('charGroupOpponent', DropDown); if (charGroupOpponent == null) throw 'Could not locate charGroupOpponent DropDown in Song Metadata dialog'; - charGroupOpponent.onChange = function(event:UIEvent) { + 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<DropDown> = 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<Button> = charGroup.findComponent('charGroupRemove', Button); if (charGroupRemove == null) throw 'Could not locate charGroupRemove Button in Song Metadata dialog'; - charGroupRemove.onClick = function(event:UIEvent) { + charGroupRemove.onClick = function(event:UIEvent):Void { removeGroup(); }; diff --git a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx index 4ee6eda9f..af1b605a0 100644 --- a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx +++ b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx @@ -1,6 +1,6 @@ package funkin.ui.debug.charting; -import funkin.play.event.SongEventData.SongEventParser; +import funkin.data.event.SongEventData.SongEventParser; import flixel.graphics.frames.FlxAtlasFrames; import openfl.display.BitmapData; import openfl.utils.Assets; @@ -10,7 +10,7 @@ import flixel.FlxSprite; import flixel.graphics.frames.FlxFramesCollection; import flixel.graphics.frames.FlxTileFrames; import flixel.math.FlxPoint; -import funkin.play.song.SongData.SongEventData; +import funkin.data.song.SongData.SongEventData; /** * A event sprite that can be used to display a song event in a chart. diff --git a/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx index ebf65c001..d64cc33a1 100644 --- a/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx +++ b/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx @@ -8,7 +8,7 @@ import flixel.graphics.frames.FlxFramesCollection; import flixel.graphics.frames.FlxTileFrames; import flixel.math.FlxPoint; import funkin.play.notes.SustainTrail; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongNoteData; /** * A hold note sprite that can be used to display a note in a chart. diff --git a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx index be45676f2..6119141cc 100644 --- a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx +++ b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx @@ -1,7 +1,7 @@ package funkin.ui.debug.charting; -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 flixel.math.FlxMath; import flixel.FlxSprite; import flixel.util.FlxColor; diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx index 10e0f9045..c8fe8f598 100644 --- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx +++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx @@ -7,7 +7,7 @@ import flixel.graphics.frames.FlxAtlasFrames; import flixel.graphics.frames.FlxFrame; import flixel.graphics.frames.FlxTileFrames; import flixel.math.FlxPoint; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongNoteData; /** * A note sprite that can be used to display a note in a chart. diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index c0cb473e2..c0d99dabc 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -36,13 +36,13 @@ import funkin.play.notes.NoteSprite; import funkin.play.notes.Strumline; import funkin.play.PlayState; import funkin.play.song.Song; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongDataParser; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongMetadata; -import funkin.play.song.SongData.SongNoteData; -import funkin.play.song.SongData.SongPlayableChar; -import funkin.play.song.SongDataUtils; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongRegistry; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongPlayableChar; +import funkin.data.song.SongDataUtils; import funkin.ui.debug.charting.ChartEditorCommand; import funkin.ui.debug.charting.ChartEditorCommand; import funkin.ui.debug.charting.ChartEditorThemeHandler.ChartEditorTheme; @@ -865,7 +865,7 @@ class ChartEditorState extends HaxeUIState var result:Null<SongChartData> = songChartData.get(selectedVariation); if (result == null) { - result = new SongChartData(1.0, [], []); + result = new SongChartData(["normal" => 1.0], [], ["normal" => []]); songChartData.set(selectedVariation, result); } return result; @@ -2904,6 +2904,7 @@ class ChartEditorState extends HaxeUIState // If this gets too big, something needs to be optimized somewhere! -Eric FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length); FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length); + FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length); } function buildSelectionSquare():FlxSprite @@ -4037,7 +4038,7 @@ class ChartEditorState extends HaxeUIState */ public function loadSongAsTemplate(songId:String):Void { - var song:Null<Song> = SongDataParser.fetchSong(songId); + var song:Null<Song> = SongRegistry.instance.fetchEntry(songId); if (song == null) return; @@ -4052,26 +4053,14 @@ class ChartEditorState extends HaxeUIState var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation; // Clone to prevent modifying the original. - var metadataClone = Reflect.copy(metadata); + var metadataClone:SongMetadata = metadata.clone(variation); if (metadataClone != null) songMetadata.set(variation, metadataClone); - songChartData.set(variation, SongDataParser.parseSongChartData(songId, metadata.variation)); + songChartData.set(variation, SongRegistry.instance.parseEntryChartData(songId, metadata.variation)); } loadSong(songMetadata, songChartData); - notePreviewDirty = true; - notePreviewViewportBoundsDirty = true; - - if (audioInstTrack != null) - { - audioInstTrack.stop(); - audioInstTrack = null; - } - - Conductor.forceBPM(null); // Disable the forced BPM. - Conductor.mapTimeChanges(currentSongMetadata.timeChanges); - sortChartData(); clearVocals(); @@ -4117,6 +4106,8 @@ class ChartEditorState extends HaxeUIState Conductor.forceBPM(null); // Disable the forced BPM. Conductor.mapTimeChanges(currentSongMetadata.timeChanges); + notePreviewDirty = true; + notePreviewViewportBoundsDirty = true; difficultySelectDirty = true; opponentPreviewDirty = true; playerPreviewDirty = true; diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx index f67a69112..7833e19fd 100644 --- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx @@ -4,8 +4,8 @@ import haxe.ui.containers.TreeView; import haxe.ui.containers.TreeViewNode; import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.event.SongEvent; -import funkin.play.event.SongEventData; -import funkin.play.song.SongData.SongTimeChange; +import funkin.data.event.SongEventData; +import funkin.data.song.SongData.SongTimeChange; import funkin.play.song.SongSerializer; import funkin.ui.haxeui.components.CharacterPlayer; import haxe.ui.components.Button; @@ -569,9 +569,9 @@ class ChartEditorToolboxHandler if (inputNoteSkin == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteSkin component.'; inputNoteSkin.onChange = function(event:UIEvent) { if ((event?.data?.id ?? null) == null) return; - state.currentSongMetadata.playData.noteSkin = event.data.id; + state.currentSongNoteSkin = event.data.id; }; - inputNoteSkin.value = state.currentSongMetadata.playData.noteSkin; + inputNoteSkin.value = state.currentSongNoteSkin; var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper); if (inputBPM == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputBPM component.'; @@ -581,7 +581,7 @@ class ChartEditorToolboxHandler var timeChanges:Array<SongTimeChange> = 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 { diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx index 764606bf3..fd2e3ea49 100644 --- a/source/funkin/ui/story/Level.hx +++ b/source/funkin/ui/story/Level.hx @@ -4,6 +4,7 @@ import flixel.FlxSprite; import flixel.util.FlxColor; import funkin.play.song.Song; import funkin.data.IRegistryEntry; +import funkin.data.song.SongRegistry; import funkin.data.level.LevelRegistry; import funkin.data.level.LevelData; @@ -70,17 +71,20 @@ class Level implements IRegistryEntry<LevelData> public function getSongDisplayNames(difficulty:String):Array<String> { var songList:Array<String> = getSongs() ?? []; - var songNameList:Array<String> = songList.map(function(songId) { - var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId); - if (song == null) return 'Unknown'; - var songDifficulty:SongDifficulty = song.getDifficulty(difficulty); - if (songDifficulty == null) songDifficulty = song.getDifficulty(); - var songName:String = songDifficulty?.songName; - return songName ?? 'Unknown'; + var songNameList:Array<String> = songList.map(function(songId:String) { + return getSongDisplayName(songId, difficulty); }); return songNameList; } + static function getSongDisplayName(songId:String, difficulty:String):String + { + var song:Null<Song> = SongRegistry.instance.fetchEntry(songId); + if (song == null) return 'Unknown'; + + return song.songName; + } + /** * Whether this level is unlocked. If not, it will be greyed out on the menu and have a lock icon. * TODO: Change this behavior in a later release. @@ -141,7 +145,7 @@ class Level implements IRegistryEntry<LevelData> var songList = getSongs(); var firstSongId:String = songList[0]; - var firstSong:Song = funkin.play.song.SongData.SongDataParser.fetchSong(firstSongId); + var firstSong:Song = SongRegistry.instance.fetchEntry(firstSongId); if (firstSong != null) { @@ -155,7 +159,7 @@ class Level implements IRegistryEntry<LevelData> for (songIndex in 1...songList.length) { var songId:String = songList[songIndex]; - var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId); + var song:Song = SongRegistry.instance.fetchEntry(songId); if (song == null) continue; @@ -200,7 +204,7 @@ class Level implements IRegistryEntry<LevelData> return 'Level($id)'; } - public function _fetchData(id:String):Null<LevelData> + static function _fetchData(id:String):Null<LevelData> { return LevelRegistry.instance.parseEntryDataWithMigration(id, LevelRegistry.instance.fetchEntryVersion(id)); } diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx index 34dd49e22..3a5a388a8 100644 --- a/source/funkin/ui/story/StoryMenuState.hx +++ b/source/funkin/ui/story/StoryMenuState.hx @@ -16,8 +16,8 @@ import funkin.modding.events.ScriptEventDispatcher; import funkin.play.PlayState; import funkin.play.PlayStatePlaylist; import funkin.play.song.Song; -import funkin.play.song.SongData.SongMetadata; -import funkin.play.song.SongData.SongDataParser; +import funkin.data.song.SongData.SongMusicData; +import funkin.data.song.SongRegistry; class StoryMenuState extends MusicBeatState { @@ -209,8 +209,11 @@ class StoryMenuState extends MusicBeatState { if (FlxG.sound.music == null || !FlxG.sound.music.playing) { - var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu'); - Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges); + var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu'); + if (freakyMenuMetadata != null) + { + Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges); + } FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0); FlxG.sound.music.fadeIn(4, 0, 0.7); @@ -514,7 +517,7 @@ class StoryMenuState extends MusicBeatState var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift(); - var targetSong:Song = SongDataParser.fetchSong(targetSongId); + var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId); PlayStatePlaylist.campaignId = currentLevel.id; PlayStatePlaylist.campaignTitle = currentLevel.getTitle(); diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx index 8b832f789..313c578a3 100644 --- a/source/funkin/ui/title/TitleState.hx +++ b/source/funkin/ui/title/TitleState.hx @@ -12,8 +12,8 @@ import flixel.util.FlxTimer; import funkin.audiovis.SpectogramSprite; import funkin.shaderslmfao.ColorSwap; import funkin.shaderslmfao.LeftMaskShader; -import funkin.play.song.SongData.SongDataParser; -import funkin.play.song.SongData.SongMetadata; +import funkin.data.song.SongRegistry; +import funkin.data.song.SongData.SongMusicData; import funkin.shaderslmfao.TitleOutline; import funkin.ui.AtlasText; import openfl.Assets; @@ -216,9 +216,11 @@ class TitleState extends MusicBeatState { if (FlxG.sound.music == null || !FlxG.sound.music.playing) { - var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu'); - Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges); - + var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu'); + if (freakyMenuMetadata != null) + { + Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges); + } FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0); FlxG.sound.music.fadeIn(4, 0, 0.7); } diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx index df45e0717..0e96f7ec8 100644 --- a/source/funkin/util/SortUtil.hx +++ b/source/funkin/util/SortUtil.hx @@ -1,5 +1,6 @@ package funkin.util; +import flixel.graphics.frames.FlxFrame; #if !macro import flixel.FlxBasic; import flixel.util.FlxSort; @@ -41,6 +42,16 @@ class SortUtil return FlxSort.byValues(order, a.noteData.time, b.noteData.time); } + /** + * Given two FlxFrames, sort their names alphabetically. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + */ + public static inline function byFrameName(a:FlxFrame, b:FlxFrame) + { + return alphabetically(a.name, b.name); + } + /** * Sort predicate for sorting strings alphabetically. * @param a The first string to compare. diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx index 368bb12de..1dc00473a 100644 --- a/source/funkin/util/VersionUtil.hx +++ b/source/funkin/util/VersionUtil.hx @@ -51,8 +51,9 @@ class VersionUtil * @param input The JSON string to parse. * @return The semantic version, or null if it could not be parsed. */ - public static function getVersionFromJSON(input:String):Null<thx.semver.Version> + public static function getVersionFromJSON(input:Null<String>):Null<thx.semver.Version> { + if (input == null) return null; var parsed = SerializerUtil.fromJSON(input); if (parsed == null) return null; if (parsed.version == null) return null; diff --git a/source/funkin/util/tools/IteratorTools.hx b/source/funkin/util/tools/IteratorTools.hx index 9279c1227..49d9477f6 100644 --- a/source/funkin/util/tools/IteratorTools.hx +++ b/source/funkin/util/tools/IteratorTools.hx @@ -13,4 +13,22 @@ class IteratorTools { return [for (i in iterator) i]; } + + public static function count<T>(iterator:Iterator<T>, ?predicate:(item:T) -> Bool):Int + { + var n = 0; + + if (predicate == null) + { + for (_ in iterator) + n++; + } + else + { + for (x in iterator) + if (predicate(x)) n++; + } + + return n; + } } diff --git a/tests/unit/assets/preload/data/notestyles/funkin.json b/tests/unit/assets/preload/data/notestyles/funkin.json index abb039150..a36dd8c1c 100644 --- a/tests/unit/assets/preload/data/notestyles/funkin.json +++ b/tests/unit/assets/preload/data/notestyles/funkin.json @@ -7,7 +7,7 @@ "note": { "assetPath": "shared:arrows", "scale": 1.0, - "isPixel": true, + "isPixel": false, "data": { "left": { "prefix": "noteLeft" }, "down": { "prefix": "noteDown" }, @@ -19,7 +19,7 @@ "assetPath": "shared:arrows", "scale": 1.0, "offsets": [28, 32], - "isPixel": true, + "isPixel": false, "data": { "leftStatic": { "prefix": "staticLeft0" }, "leftPress": { "prefix": "pressedLeft0" }, diff --git a/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json new file mode 100644 index 000000000..c7f7629d6 --- /dev/null +++ b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "songName": "Bopeebo", + "artist": "Kawai Sprite", + "timeFormat": "ms", + "timeChanges": [{ "t": 0, "bpm": 100, "n": 4, "d": 4, "bt": [4, 4, 4, 4] }], + "playData": { + "songVariations": [], + "difficulties": ["easy", "normal", "hard"], + "playableChars": { "bf": { "g": "gf", "o": "dad" } }, + "stage": "mainStage", + "noteSkin": "Normal" + }, + "generatedBy": "MasterEric (by hand)" +} diff --git a/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json new file mode 100644 index 000000000..53817f96a --- /dev/null +++ b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json @@ -0,0 +1,1738 @@ +{ + "version": "2.0.0", + "scrollSpeed": { + "default": 1.0, + "hard": 1.3 + }, + "events": [ + { "t": 0, "e": "FocusCamera", "v": 1 }, + { "t": 2400, "e": "FocusCamera", "v": 0 }, + { + "t": 4200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 4800, "e": "FocusCamera", "v": 1 }, + { "t": 7200, "e": "FocusCamera", "v": 0 }, + { + "t": 9000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 9600, "e": "FocusCamera", "v": 1 }, + { "t": 12000, "e": "FocusCamera", "v": 0 }, + { + "t": 13800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 14400, "e": "FocusCamera", "v": 1 }, + { "t": 16800, "e": "FocusCamera", "v": 0 }, + { + "t": 18600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 19200, "e": "FocusCamera", "v": 1 }, + { "t": 21600, "e": "FocusCamera", "v": 0 }, + { + "t": 23400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 24000, "e": "FocusCamera", "v": 1 }, + { "t": 26400, "e": "FocusCamera", "v": 0 }, + { + "t": 28200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 28800, "e": "FocusCamera", "v": 1 }, + { "t": 31200, "e": "FocusCamera", "v": 0 }, + { + "t": 33000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 33600, "e": "FocusCamera", "v": 1 }, + { "t": 36000, "e": "FocusCamera", "v": 0 }, + { + "t": 37800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 38400, "e": "FocusCamera", "v": 1 }, + { "t": 40800, "e": "FocusCamera", "v": 0 }, + { + "t": 42600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 43200, "e": "FocusCamera", "v": 1 }, + { "t": 45600, "e": "FocusCamera", "v": 0 }, + { + "t": 47400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 48000, "e": "FocusCamera", "v": 1 }, + { "t": 50400, "e": "FocusCamera", "v": 0 }, + { + "t": 52200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 52800, "e": "FocusCamera", "v": 1 }, + { "t": 55200, "e": "FocusCamera", "v": 0 }, + { + "t": 57000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 57600, "e": "FocusCamera", "v": 1 }, + { "t": 60000, "e": "FocusCamera", "v": 0 }, + { + "t": 61800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 62400, "e": "FocusCamera", "v": 1 }, + { "t": 64800, "e": "FocusCamera", "v": 0 }, + { + "t": 66600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 67200, "e": "FocusCamera", "v": 1 }, + { "t": 69600, "e": "FocusCamera", "v": 0 }, + { + "t": 71400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 72000, "e": "FocusCamera", "v": 1 }, + { "t": 74400, "e": "FocusCamera", "v": 0 }, + { + "t": 76200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + } + ], + "notes": { + "easy": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 450 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 450 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ], + "normal": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 600 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 600 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5, + "l": 300 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1, + "l": 300 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33300, + "d": 6 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 35700, + "d": 2 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44550, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 46950, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62100, + "d": 6 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64500, + "d": 2 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 71700, + "d": 6 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74100, + "d": 2 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ], + "hard": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 600 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 600 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5, + "l": 300 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1, + "l": 300 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33300, + "d": 6 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34575, + "d": 4 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 35700, + "d": 2 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 36975, + "d": 0 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44550, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 46950, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62100, + "d": 6 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63375, + "d": 4 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64500, + "d": 2 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 65775, + "d": 0 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 71700, + "d": 6 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 72975, + "d": 4 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74100, + "d": 2 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75375, + "d": 0 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ] + }, + "generatedBy": "MasterEric (by hand)" +} diff --git a/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json new file mode 100644 index 000000000..53817f96a --- /dev/null +++ b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json @@ -0,0 +1,1738 @@ +{ + "version": "2.0.0", + "scrollSpeed": { + "default": 1.0, + "hard": 1.3 + }, + "events": [ + { "t": 0, "e": "FocusCamera", "v": 1 }, + { "t": 2400, "e": "FocusCamera", "v": 0 }, + { + "t": 4200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 4800, "e": "FocusCamera", "v": 1 }, + { "t": 7200, "e": "FocusCamera", "v": 0 }, + { + "t": 9000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 9600, "e": "FocusCamera", "v": 1 }, + { "t": 12000, "e": "FocusCamera", "v": 0 }, + { + "t": 13800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 14400, "e": "FocusCamera", "v": 1 }, + { "t": 16800, "e": "FocusCamera", "v": 0 }, + { + "t": 18600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 19200, "e": "FocusCamera", "v": 1 }, + { "t": 21600, "e": "FocusCamera", "v": 0 }, + { + "t": 23400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 24000, "e": "FocusCamera", "v": 1 }, + { "t": 26400, "e": "FocusCamera", "v": 0 }, + { + "t": 28200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 28800, "e": "FocusCamera", "v": 1 }, + { "t": 31200, "e": "FocusCamera", "v": 0 }, + { + "t": 33000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 33600, "e": "FocusCamera", "v": 1 }, + { "t": 36000, "e": "FocusCamera", "v": 0 }, + { + "t": 37800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 38400, "e": "FocusCamera", "v": 1 }, + { "t": 40800, "e": "FocusCamera", "v": 0 }, + { + "t": 42600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 43200, "e": "FocusCamera", "v": 1 }, + { "t": 45600, "e": "FocusCamera", "v": 0 }, + { + "t": 47400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 48000, "e": "FocusCamera", "v": 1 }, + { "t": 50400, "e": "FocusCamera", "v": 0 }, + { + "t": 52200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 52800, "e": "FocusCamera", "v": 1 }, + { "t": 55200, "e": "FocusCamera", "v": 0 }, + { + "t": 57000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 57600, "e": "FocusCamera", "v": 1 }, + { "t": 60000, "e": "FocusCamera", "v": 0 }, + { + "t": 61800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 62400, "e": "FocusCamera", "v": 1 }, + { "t": 64800, "e": "FocusCamera", "v": 0 }, + { + "t": 66600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 67200, "e": "FocusCamera", "v": 1 }, + { "t": 69600, "e": "FocusCamera", "v": 0 }, + { + "t": 71400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 72000, "e": "FocusCamera", "v": 1 }, + { "t": 74400, "e": "FocusCamera", "v": 0 }, + { + "t": 76200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + } + ], + "notes": { + "easy": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 450 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 450 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ], + "normal": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 600 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 600 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5, + "l": 300 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1, + "l": 300 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33300, + "d": 6 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 35700, + "d": 2 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44550, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 46950, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62100, + "d": 6 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64500, + "d": 2 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 71700, + "d": 6 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74100, + "d": 2 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ], + "hard": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 600 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 600 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5, + "l": 300 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1, + "l": 300 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33300, + "d": 6 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34575, + "d": 4 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 35700, + "d": 2 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 36975, + "d": 0 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44550, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 46950, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62100, + "d": 6 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63375, + "d": 4 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64500, + "d": 2 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 65775, + "d": 0 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 71700, + "d": 6 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 72975, + "d": 4 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74100, + "d": 2 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75375, + "d": 0 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ] + }, + "generatedBy": "MasterEric (by hand)" +} diff --git a/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json new file mode 100644 index 000000000..c7f7629d6 --- /dev/null +++ b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "songName": "Bopeebo", + "artist": "Kawai Sprite", + "timeFormat": "ms", + "timeChanges": [{ "t": 0, "bpm": 100, "n": 4, "d": 4, "bt": [4, 4, 4, 4] }], + "playData": { + "songVariations": [], + "difficulties": ["easy", "normal", "hard"], + "playableChars": { "bf": { "g": "gf", "o": "dad" } }, + "stage": "mainStage", + "noteSkin": "Normal" + }, + "generatedBy": "MasterEric (by hand)" +} diff --git a/tests/unit/project.xml b/tests/unit/project.xml index 63f164607..2e505e015 100644 --- a/tests/unit/project.xml +++ b/tests/unit/project.xml @@ -44,11 +44,12 @@ <!-- Assets --> <assets path="assets/preload" rename="assets" exclude="*.ogg" if="web" /> <assets path="assets/preload" rename="assets" exclude="*.mp3" unless="web" /> + <assets path="assets/shared" library="shared" exclude="*.fla|*.ogg" if="web" /> + <assets path="assets/shared" library="shared" exclude="*.fla|*.mp3" unless="web" /> + <library name="shared" preload="true" /> <!-- <assets path="assets/songs" library="songs" exclude="*.fla|*.ogg" if="web" /> <assets path="assets/songs" library="songs" exclude="*.fla|*.mp3" unless="web" /> - <assets path="assets/shared" library="shared" exclude="*.fla|*.ogg" if="web" /> - <assets path="assets/shared" library="shared" exclude="*.fla|*.mp3" unless="web" /> <assets path="assets/tutorial" library="tutorial" exclude="*.fla|*.ogg" if="web" /> <assets path="assets/tutorial" library="tutorial" exclude="*.fla|*.mp3" unless="web" /> <assets path="assets/week1" library="week1" exclude="*.fla|*.ogg" if="web" /> @@ -68,7 +69,6 @@ <assets path="assets/weekend1" library="weekend1" exclude="*.fla|*.ogg" if="web" /> <assets path="assets/weekend1" library="weekend1" exclude="*.fla|*.mp3" unless="web" /> <library name="songs" preload="true" /> - <library name="shared" preload="true" /> <library name="tutorial" preload="true" /> <library name="week1" preload="true" /> <library name="week2" preload="true" /> @@ -87,8 +87,8 @@ <haxedef name="FLX_RECORD" /> <!-- Clean up the output --> - <haxedef name="no-traces" /> <!-- + <haxedef name="echo-traces" /> --> <haxedef name="ignore-inline" /> <haxeflag name="-w" value="-WDeprecated" /> diff --git a/tests/unit/source/FunkinAssert.hx b/tests/unit/source/FunkinAssert.hx index 00c3a9e00..6a0b0a9a4 100644 --- a/tests/unit/source/FunkinAssert.hx +++ b/tests/unit/source/FunkinAssert.hx @@ -127,4 +127,54 @@ class FunkinAssert }; validateThrows(targetFunc, predicate, info); } + + static var capturedTraces:Array<String> = []; + + public static function initAssertTrace():Void + { + var oldTrace = haxe.Log.trace; + haxe.Log.trace = function(v:Dynamic, ?infos:haxe.PosInfos) { + onTrace(v, infos); + // oldTrace(v, infos); + }; + } + + public static function clearTraces():Void + { + capturedTraces = []; + } + + @:nullSafety(Off) // Why isn't haxe.std null-safe? + static function onTrace(v:Dynamic, ?infos:haxe.PosInfos) + { + // var str:String = haxe.Log.formatOutput(v, infos); + var str:String = Std.string(v); + capturedTraces.push(str); + + #if (sys && echo_traces) + Sys.println('[TESTLOG] $str'); + #end + } + + /** + * Check the first string that was traced and validate it. + * @param expected + */ + public static inline function assertTrace(expected:String):Void + { + var actual:Null<String> = capturedTraces.shift(); + Assert.isNotNull(actual); + Assert.areEqual(expected, actual); + } + + /** + * Check the first string that was traced and validate it. + * @param expected + */ + public static inline function assertLastTrace(expected:String):Void + { + var actual:Null<String> = capturedTraces.pop(); + Assert.isNotNull(actual); + Assert.areEqual(expected, actual); + } } diff --git a/tests/unit/source/MockTest.hx b/tests/unit/source/MockTest.hx index 308dbb45a..e9345dc2d 100644 --- a/tests/unit/source/MockTest.hx +++ b/tests/unit/source/MockTest.hx @@ -30,8 +30,8 @@ class MockTest extends FunkinTest { // Test that mocking works. - var mockSprite = mockatoo.Mockatoo.mock(flixel.FlxSprite); - var mockAnim = mockatoo.Mockatoo.mock(flixel.animation.FlxAnimationController); + var mockSprite = Mockatoo.mock(flixel.FlxSprite); + var mockAnim = Mockatoo.mock(flixel.animation.FlxAnimationController); mockSprite.animation = mockAnim; var animData:funkin.data.animation.AnimationData = @@ -44,12 +44,12 @@ class MockTest extends FunkinTest // Verify that the method was called once. // If not, a VerificationException will be thrown and the test will fail. - mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1)); + mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1)); FunkinAssert.validateThrows(function() { // Attempt to verify the method was called. // This should FAIL, since we didn't call the method. - mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1)); + mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1)); }, function(err) { return Std.isOfType(err, mockatoo.exception.VerificationException); }); diff --git a/tests/unit/source/funkin/ConductorTest.hx b/tests/unit/source/funkin/ConductorTest.hx index 0cfbe3960..c65f3f297 100644 --- a/tests/unit/source/funkin/ConductorTest.hx +++ b/tests/unit/source/funkin/ConductorTest.hx @@ -3,7 +3,7 @@ package funkin; import flixel.FlxG; import flixel.FlxState; import funkin.Conductor; -import funkin.play.song.SongData.SongTimeChange; +import funkin.data.song.SongData.SongTimeChange; import funkin.util.Constants; import massive.munit.Assert; @@ -16,6 +16,8 @@ class ConductorTest extends FunkinTest @Before function before() { + FunkinAssert.initAssertTrace(); + resetGame(); // The ConductorState will advance the conductor when step() is called. @@ -193,16 +195,7 @@ class ConductorTest extends FunkinTest function testSingleTimeChange():Void { // Start the song with a BPM of 120. - var songTimeChanges:Array<SongTimeChange> = [ - { - t: 0, - b: 0, - bpm: 120, - n: 4, - d: 4, - bt: [4, 4, 4, 4] - }, // 120 bpm starting 0 sec/0 beats - ]; + var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120)]; Conductor.mapTimeChanges(songTimeChanges); // All should be at 0. @@ -253,24 +246,7 @@ class ConductorTest extends FunkinTest function testDoubleTimeChange():Void { // Start the song with a BPM of 120. - var songTimeChanges:Array<SongTimeChange> = [ - { - t: 0, - b: 0, - bpm: 120, - n: 4, - d: 4, - bt: [4, 4, 4, 4] - }, // 120 bpm starting 0 sec/0 beats - { - t: 3000, - b: 6, - bpm: 90, - n: 4, - d: 4, - bt: [4, 4, 4, 4] - } // 90 bpm starting 3 sec/6 beats - ]; + var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120), new SongTimeChange(3000, 90)]; Conductor.mapTimeChanges(songTimeChanges); // All should be at 0. @@ -354,30 +330,9 @@ class ConductorTest extends FunkinTest { // Start the song with a BPM of 120, then move to 90, then move to 180. var songTimeChanges:Array<SongTimeChange> = [ - { - t: 0, - b: null, - bpm: 120, - n: 4, - d: 4, - bt: [4, 4, 4, 4] - }, // 120 bpm starting 0 sec/0 beats - { - t: 3000, - b: null, - bpm: 90, - n: 4, - d: 4, - bt: [4, 4, 4, 4] - }, // 90 bpm starting 3 sec/6 beats - { - t: 6000, - b: null, - bpm: 180, - n: 4, - d: 4, - bt: [4, 4, 4, 4] - } // 90 bpm starting 3 sec/6 beats + new SongTimeChange(0, 120), + new SongTimeChange(3000, 90), + new SongTimeChange(6000, 180) ]; Conductor.mapTimeChanges(songTimeChanges); diff --git a/tests/unit/source/funkin/data/BaseRegistryTest.hx b/tests/unit/source/funkin/data/BaseRegistryTest.hx index 31e44b6ed..0be932d35 100644 --- a/tests/unit/source/funkin/data/BaseRegistryTest.hx +++ b/tests/unit/source/funkin/data/BaseRegistryTest.hx @@ -17,7 +17,10 @@ class BaseRegistryTest extends FunkinTest } @BeforeClass - public function beforeClass() {} + public function beforeClass() + { + FunkinAssert.initAssertTrace(); + } @AfterClass public function afterClass() {} @@ -118,7 +121,7 @@ class MyType implements IRegistryEntry<MyTypeData> return 'MyType($id)'; } - public function _fetchData(id:String):Null<MyTypeData> + static function _fetchData(id:String):Null<MyTypeData> { return MyTypeRegistry.instance.parseEntryDataWithMigration(id, MyTypeRegistry.instance.fetchEntryVersion(id)); } @@ -153,17 +156,18 @@ class MyTypeRegistry extends BaseRegistry<MyType, MyTypeData> // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<MyTypeData>(); - 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; @@ -177,31 +181,33 @@ class MyTypeRegistry extends BaseRegistry<MyType, MyTypeData> // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<MyTypeData_v0_1_x>(); - 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; } var oldData:MyTypeData_v0_1_x = parser.value; + return migrateData_v0_1_x(oldData); + } - var result:MyTypeData = - { - version: DATA_VERSION, - id: '${oldData.id}', - name: oldData.name, - data: [] - }; - - return result; + function migrateData_v0_1_x(input:MyTypeData_v0_1_x):MyTypeData + { + return { + version: DATA_VERSION, + id: '${input.id}', + name: input.name, + data: [] + }; } public override function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null<MyTypeData> diff --git a/tests/unit/source/funkin/data/level/LevelRegistryTest.hx b/tests/unit/source/funkin/data/level/LevelRegistryTest.hx index 3d9cf5d29..691d8bedc 100644 --- a/tests/unit/source/funkin/data/level/LevelRegistryTest.hx +++ b/tests/unit/source/funkin/data/level/LevelRegistryTest.hx @@ -123,9 +123,12 @@ class LevelRegistryTest extends FunkinTest } @Test - @Ignore("Requires redoing validation.") public function testCreateEntryBlankPath():Void { + // Using @:jcustomparse, `titleAsset` has a validation function that ensures it is not blank. + // This test makes sure that the validation function is being called, and that the error + // results in the level failing to parse. + FunkinAssert.validateThrows(function() { var result:Null<Level> = LevelRegistry.instance.createEntry("blankpathtest"); }, function(err) { @@ -134,7 +137,6 @@ class LevelRegistryTest extends FunkinTest } @Test - @Ignore("Requires redoing validation.") public function testFetchBadEntry():Void { var result:Null<Level> = LevelRegistry.instance.fetchEntry("blablabla"); diff --git a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx index 8ae9cb31f..447ee7831 100644 --- a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx +++ b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx @@ -86,7 +86,7 @@ class NoteStyleRegistryTest extends FunkinTest @Test public function testFetchDefault():Void { - var nsrMock:NoteStyleRegistry = mock(NoteStyleRegistry); + var nsrMock = Mockatoo.mock(NoteStyleRegistry); nsrMock.fetchDefault().callsRealMethod(); diff --git a/tests/unit/source/funkin/data/song/SongRegistryTest.hx b/tests/unit/source/funkin/data/song/SongRegistryTest.hx new file mode 100644 index 000000000..c623306a6 --- /dev/null +++ b/tests/unit/source/funkin/data/song/SongRegistryTest.hx @@ -0,0 +1,133 @@ +package funkin.data.song; + +import funkin.data.song.SongData; +import funkin.data.song.SongRegistry; +import funkin.play.song.Song; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import massive.munit.util.Timer; + +@:nullSafety +@:access(funkin.play.song.Song) +@:access(funkin.data.song.SongRegistry) +class SongRegistryTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass():Void + { + FunkinAssert.initAssertTrace(); + SongRegistry.instance.loadEntries(); + } + + @AfterClass + public function afterClass():Void {} + + @Before + public function setup():Void {} + + @After + public function tearDown():Void {} + + @Test + public function testValid():Void + { + Assert.isNotNull(SongRegistry.instance); + } + + @Test + public function testParseMetadata():Void + { + var result:Null<SongData.SongMetadata> = SongRegistry.instance.parseEntryMetadata("bopeebo"); + + Assert.isNotNull(result); + + var expectedVersion:thx.semver.Version = "2.0.0"; + Assert.areEqual(expectedVersion, result.version); + Assert.areEqual("Bopeebo", result.songName); + Assert.areEqual("Kawai Sprite", result.artist); + Assert.areEqual(SongData.SongTimeFormat.MILLISECONDS, result.timeFormat); + Assert.areEqual("MasterEric (by hand)", result.generatedBy); + } + + @Test + public function testParseChartData():Void + { + var result:Null<SongData.SongChartData> = SongRegistry.instance.parseEntryChartData("bopeebo"); + + Assert.isNotNull(result); + + var expectedVersion:thx.semver.Version = "2.0.0"; + Assert.areEqual(expectedVersion, result.version); + } + + /** + * A test validating an error is thrown when attempting to parse chart data as metadata. + */ + @Test + public function testParseMetadataSwapped():Void + { + // Arrange + FunkinAssert.clearTraces(); + + // Act + var result:Null<SongData.SongMetadata> = SongRegistry.instance.parseEntryMetadata("bopeebo-swapped"); + + // Assert + Assert.isNull(result); + FunkinAssert.assertTrace("[SONG] Failed to parse entry data: bopeebo-swapped"); + FunkinAssert.assertTrace(" Unknown variable \"scrollSpeed\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:3"); + FunkinAssert.assertTrace(" Unknown variable \"events\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:7"); + FunkinAssert.assertTrace(" Unknown variable \"notes\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:185"); + FunkinAssert.assertTrace(" Uninitialized variable \"songName\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + FunkinAssert.assertTrace(" Uninitialized variable \"timeFormat\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + FunkinAssert.assertTrace(" Uninitialized variable \"timeChanges\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + FunkinAssert.assertTrace(" Uninitialized variable \"playData\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + FunkinAssert.assertTrace(" Uninitialized variable \"artist\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + } + + /** + * A test validating an error is thrown when attempting to parse metadata as chart data. + */ + @Test + public function testParseChartDataSwapped():Void + { + // Arrange + FunkinAssert.clearTraces(); + + // Act + var result:Null<SongData.SongChartData> = SongRegistry.instance.parseEntryChartData("bopeebo-swapped"); + + // Assert + Assert.isNull(result); + FunkinAssert.assertTrace("[SONG] Failed to parse entry data: bopeebo-swapped"); + FunkinAssert.assertTrace(" Unknown variable \"songName\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:3"); + FunkinAssert.assertTrace(" Unknown variable \"artist\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:4"); + FunkinAssert.assertTrace(" Unknown variable \"timeFormat\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:5"); + FunkinAssert.assertTrace(" Unknown variable \"timeChanges\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:6"); + FunkinAssert.assertTrace(" Unknown variable \"playData\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:7"); + FunkinAssert.assertTrace(" Uninitialized variable \"notes\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15"); + FunkinAssert.assertTrace(" Uninitialized variable \"events\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15"); + FunkinAssert.assertTrace(" Uninitialized variable \"scrollSpeed\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15"); + } +} diff --git a/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx index 6b4b46c10..f2d3e570c 100644 --- a/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx +++ b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx @@ -1,11 +1,18 @@ package funkin.play.notes.notestyle; +import flixel.util.FlxSort; +import funkin.util.SortUtil; +import flixel.graphics.FlxGraphic; +import flixel.graphics.frames.FlxFrame; +import flixel.graphics.frames.FlxFramesCollection; import massive.munit.util.Timer; import massive.munit.Assert; import massive.munit.async.AsyncFactory; import funkin.data.notestyle.NoteStyleRegistry; import funkin.play.notes.notestyle.NoteStyle; import flixel.animation.FlxAnimationController; +import openfl.utils.Assets; +import flixel.math.FlxPoint; @:access(funkin.play.notes.notestyle.NoteStyle) class NoteStyleTest extends FunkinTest @@ -31,20 +38,142 @@ class NoteStyleTest extends FunkinTest public function tearDown() {} @Test - @Ignore("This test doesn't work, crashes when the project has 2 mocks of the same class???") public function testBuildNoteSprite() { var target:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry("funkin"); Assert.isNotNull(target); - var mockNoteSprite:NoteSprite = mock(NoteSprite); - // var mockAnim = mock(FlxAnimationController); - // mockNoteSprite.animation = mockAnim; + // Arrange + var mockNoteSprite = Mockatoo.mock(NoteSprite); + var mockAnim = Mockatoo.mock(FlxAnimationController); + var scale = new FlxPoint(1, 1); // handle sprite.scale.x on the mock + mockNoteSprite.animation = mockAnim; // Tell the mock to forward calls to the animation controller mock. + mockNoteSprite.scale.returns(scale); // Redirect this final variable to a local variable. + mockNoteSprite.antialiasing.callsRealMethod(); // Tell the mock to treat this like a normal property. + mockNoteSprite.frames.callsRealMethod(); // Tell the mock to treat this like a normal property. + + // Act target.buildNoteSprite(mockNoteSprite); - Assert.areEqual(mockNoteSprite.frames, []); + var expectedGraphic:FlxGraphic = FlxG.bitmap.add("shared:assets/shared/images/arrows.png"); + + // Assert + Assert.isNotNull(mockNoteSprite.frames); + mockNoteSprite.frames.frames.sort(SortUtil.byFrameName); + var frameCount:Int = mockNoteSprite.frames.frames.length; + Assert.areEqual(24, frameCount); + + // Validate each frame. + for (i in 0...frameCount) + { + var currentFrame:FlxFrame = mockNoteSprite.frames.frames[i]; + switch (i) + { + case 0: + Assert.areEqual("confirmDown0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 1: + Assert.areEqual("confirmDown0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 2: + Assert.areEqual("confirmLeft0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 3: + Assert.areEqual("confirmLeft0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 4: + Assert.areEqual("confirmRight0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 5: + Assert.areEqual("confirmRight0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 6: + Assert.areEqual("confirmUp0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 7: + Assert.areEqual("confirmUp0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 8: + Assert.areEqual("noteDown0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 9: + Assert.areEqual("noteLeft0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 10: + Assert.areEqual("noteRight0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 11: + Assert.areEqual("noteUp0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 12: + Assert.areEqual("pressedDown0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 13: + Assert.areEqual("pressedDown0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 14: + Assert.areEqual("pressedLeft0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 15: + Assert.areEqual("pressedLeft0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 16: + Assert.areEqual("pressedRight0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 17: + Assert.areEqual("pressedRight0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 18: + Assert.areEqual("pressedUp0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 19: + Assert.areEqual("pressedUp0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 20: + Assert.areEqual("staticDown0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 21: + Assert.areEqual("staticLeft0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 22: + Assert.areEqual("staticRight0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 23: + Assert.areEqual("staticUp0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + default: + Assert.fail('Got unexpected frame number ${i}'); + } + } + + // Verify animations were applied. + @:privateAccess { + mockAnim.addByPrefix('purpleScroll', 'noteLeft', 24, false, false, false).verify(times(1)); + mockAnim.addByPrefix('blueScroll', 'noteDown', 24, false, false, false).verify(times(1)); + mockAnim.addByPrefix('greenScroll', 'noteUp', 24, false, false, false).verify(times(1)); + mockAnim.addByPrefix('redScroll', 'noteRight', 24, false, false, false).verify(times(1)); + mockAnim.destroyAnimations().verify(times(1)); + mockAnim.set_frameIndex(0).verify(times(1)); + // Verify there were no other functions called. + mockAnim.verifyZeroInteractions(); + } + + // Verify sprite was initialized. + @:privateAccess { + mockNoteSprite.set_graphic(expectedGraphic).verify(times(1)); + mockNoteSprite.graphicLoaded().verify(times(1)); + mockNoteSprite.set_antialiasing(true).verify(times(1)); + mockNoteSprite.set_frames(mockNoteSprite.frames).verify(times(1)); + mockNoteSprite.set_frame(mockNoteSprite.frames.frames[21]).verify(times(1)); + mockNoteSprite.resetHelpers().verify(times(1)); + + Assert.areEqual(1, mockNoteSprite.scale.x); + Assert.areEqual(1, mockNoteSprite.scale.y); + // Verify there were no other functions called. + mockNoteSprite.verifyZeroInteractions(); + } } @Test diff --git a/tests/unit/source/funkin/util/SortUtilTest.hx b/tests/unit/source/funkin/util/SortUtilTest.hx index 1a39bf655..5b868b278 100644 --- a/tests/unit/source/funkin/util/SortUtilTest.hx +++ b/tests/unit/source/funkin/util/SortUtilTest.hx @@ -3,7 +3,7 @@ package funkin.util; import flixel.FlxObject; import flixel.FlxSprite; import flixel.util.FlxSort; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongNoteData; import massive.munit.util.Timer; import massive.munit.Assert; import massive.munit.async.AsyncFactory; diff --git a/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx index 02b03055d..8adfd3565 100644 --- a/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx +++ b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx @@ -34,8 +34,8 @@ class FlxAnimationUtilTest extends FunkinTest public function testAddAtlasAnimation() { // Build a mock child class of FlxSprite - var mockSprite = mock(FlxSprite); - var mockAnim = mock(FlxAnimationController); + var mockSprite = Mockatoo.mock(FlxSprite); + var mockAnim = Mockatoo.mock(FlxAnimationController); mockSprite.animation = mockAnim; var animData:AnimationData = @@ -85,8 +85,8 @@ class FlxAnimationUtilTest extends FunkinTest public function testAddAtlasAnimations() { // Build a mock child class of FlxSprite - var mockSprite = mock(FlxSprite); - var mockAnim = mock(FlxAnimationController); + var mockSprite = Mockatoo.mock(FlxSprite); + var mockAnim = Mockatoo.mock(FlxAnimationController); mockSprite.animation = mockAnim; var animData:Array<AnimationData> = [