From 12b5f3fbc1fd7f71d66762103dd129d103e787b8 Mon Sep 17 00:00:00 2001 From: EliteMasterEric Date: Fri, 8 Sep 2023 17:44:11 -0400 Subject: [PATCH 1/4] Updated json2object. --- hmm.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/hmm.json b/hmm.json index e2670420a..26b32fbea 100644 --- a/hmm.json +++ b/hmm.json @@ -95,8 +95,10 @@ }, { "name": "json2object", - "type": "haxelib", - "version": "3.11.0" + "type": "git", + "dir": null, + "ref": "429986134031cbb1980f09d0d3d642b4b4cbcd6a", + "url": "https://github.com/elnabo/json2object" }, { "name": "lime", @@ -158,4 +160,4 @@ "version": "0.11.0" } ] -} +} \ No newline at end of file From 7e1c11bb25b0c9c5329351e4f262607076967b5d Mon Sep 17 00:00:00 2001 From: EliteMasterEric Date: Fri, 8 Sep 2023 17:45:47 -0400 Subject: [PATCH 2/4] New song data parser --- source/funkin/data/BaseRegistry.hx | 95 ++- source/funkin/data/DataParse.hx | 99 +++ source/funkin/data/DataWrite.hx | 8 + source/funkin/data/IRegistryEntry.hx | 3 +- source/funkin/data/README.md | 21 + source/funkin/data/event/SongEventData.hx | 236 +++++++ source/funkin/data/level/LevelData.hx | 2 + source/funkin/data/level/LevelRegistry.hx | 15 +- .../data/notestyle/NoteStyleRegistry.hx | 17 +- source/funkin/data/song/SongData.hx | 649 ++++++++++++++++++ source/funkin/data/song/SongDataUtils.hx | 232 +++++++ source/funkin/data/song/SongRegistry.hx | 262 +++++++ 12 files changed, 1618 insertions(+), 21 deletions(-) create mode 100644 source/funkin/data/DataParse.hx create mode 100644 source/funkin/data/DataWrite.hx create mode 100644 source/funkin/data/README.md create mode 100644 source/funkin/data/event/SongEventData.hx create mode 100644 source/funkin/data/song/SongData.hx create mode 100644 source/funkin/data/song/SongDataUtils.hx create mode 100644 source/funkin/data/song/SongRegistry.hx diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx index 98393fda4..24d0de476 100644 --- a/source/funkin/data/BaseRegistry.hx +++ b/source/funkin/data/BaseRegistry.hx @@ -4,6 +4,9 @@ import openfl.Assets; import funkin.util.assets.DataAssets; import funkin.util.VersionUtil; import haxe.Constraints.Constructible; +import json2object.Position; +import json2object.Position.Line; +import json2object.Error; /** * The entry's constructor function must take a single argument, the entry's ID. @@ -135,7 +138,7 @@ abstract class BaseRegistry & Constructible { - var entryStr:String = loadEntryFile(id); + var entryStr:String = loadEntryFile(id).contents; var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } @@ -145,11 +148,14 @@ abstract class BaseRegistry & Constructible & Constructible & Constructible; + + function printErrors(errors:Array, id:String = ''):Void + { + trace('[${registryId}] Failed to parse entry data: ${id}'); + + for (error in errors) + printError(error); + } + + function printError(error:Error):Void + { + switch (error) + { + case IncorrectType(vari, expected, pos): + trace(' Expected field "$vari" to be of type "$expected".'); + printPos(pos); + case IncorrectEnumValue(value, expected, pos): + trace(' Invalid enum value (expected "$expected", got "$value")'); + printPos(pos); + case InvalidEnumConstructor(value, expected, pos): + trace(' Invalid enum constructor (epxected "$expected", got "$value")'); + printPos(pos); + case UninitializedVariable(vari, pos): + trace(' Uninitialized variable "$vari"'); + printPos(pos); + case UnknownVariable(vari, pos): + trace(' Unknown variable "$vari"'); + printPos(pos); + case ParserError(message, pos): + trace(' Parsing error: ${message}'); + printPos(pos); + case CustomFunctionException(e, pos): + if (Std.isOfType(e, String)) + { + trace(' ${e}'); + } + else + { + printUnknownError(e); + } + printPos(pos); + default: + printUnknownError(error); + } + } + + function printUnknownError(e:Dynamic):Void + { + switch (Type.typeof(e)) + { + case TClass(c): + trace(' [${Type.getClassName(c)}] ${e.toString()}'); + case TEnum(c): + trace(' [${Type.getEnumName(c)}] ${e.toString()}'); + default: + trace(' [${Type.typeof(e)}] ${e.toString()}'); + } + } + + /** + * TODO: Figure out the nicest way to print this. + * Maybe look up how other JSON parsers format their errors? + * @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx + */ + function printPos(pos:Position):Void + { + if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number) + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}'); + } + else + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}'); + } + } } + +typedef JsonFile = +{ + fileName:String, + contents:String +}; diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx new file mode 100644 index 000000000..8a78e7c97 --- /dev/null +++ b/source/funkin/data/DataParse.hx @@ -0,0 +1,99 @@ +package funkin.data; + +import hxjsonast.Json; +import hxjsonast.Json.JObjectField; + +/** + * `json2object` has an annotation `@:jcustomparse` which allows for mutation of parsed values. + * + * It also allows for validation, since throwing an error in this function will cause the issue to be properly caught. + * Parsing will fail and `parser.errors` will contain the thrown exception. + * + * Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property. + */ +class DataParse +{ + /** + * `@:jcustomparse(funkin.data.DataParse.stringNotEmpty)` + * @param json Contains the `pos` and `value` of the property. + * @param name The name of the property. + * @throws If the property is not a string or is empty. + */ + public static function stringNotEmpty(json:Json, name:String):String + { + switch (json.value) + { + case JString(s): + if (s == "") throw 'Expected property $name to be non-empty.'; + return s; + default: + throw 'Expected property $name to be a string, but it was ${json.value}.'; + } + } + + /** + * Parser which outputs a Dynamic value, either a object or something else. + * @param json + * @param name + * @return The value of the property. + */ + public static function dynamicValue(json:Json, name:String):Dynamic + { + return jsonToDynamic(json); + } + + /** + * Parser which outputs a Dynamic value, which must be an object with properties. + * @param json + * @param name + * @return Dynamic + */ + public static function dynamicObject(json:Json, name:String):Dynamic + { + switch (json.value) + { + case JObject(fields): + return jsonFieldsToDynamicObject(fields); + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + static function jsonToDynamic(json:Json):Null + { + return switch (json.value) + { + case JString(s): s; + case JNumber(n): n; + case JBool(b): b; + case JNull: null; + case JObject(fields): jsonFieldsToDynamicObject(fields); + case JArray(values): jsonArrayToDynamicArray(values); + } + } + + /** + * Array of JSON fields `[{key, value}, {key, value}]` to a Dynamic object `{key:value, key:value}`. + * @param fields + * @return Dynamic + */ + static function jsonFieldsToDynamicObject(fields:Array):Dynamic + { + var result:Dynamic = {}; + for (field in fields) + { + Reflect.setField(result, field.name, field.value); + } + return result; + } + + /** + * Array of JSON elements `[Json, Json, Json]` to a Dynamic array `[String, Object, Int, Array]` + * @param jsons + * @return Array + */ + static function jsonArrayToDynamicArray(jsons:Array):Array> + { + return [for (json in jsons) jsonToDynamic(json)]; + } +} diff --git a/source/funkin/data/DataWrite.hx b/source/funkin/data/DataWrite.hx new file mode 100644 index 000000000..2ff7672da --- /dev/null +++ b/source/funkin/data/DataWrite.hx @@ -0,0 +1,8 @@ +package funkin.data; + +/** + * `json2object` has an annotation `@:jcustomwrite` which allows for custom serialization of values to be written to JSON. + * + * Functions must be of the signature `(T) -> String`, where `T` is the type of the property. + */ +class DataWrite {} diff --git a/source/funkin/data/IRegistryEntry.hx b/source/funkin/data/IRegistryEntry.hx index 0fb704b7c..ff506767d 100644 --- a/source/funkin/data/IRegistryEntry.hx +++ b/source/funkin/data/IRegistryEntry.hx @@ -15,5 +15,6 @@ interface IRegistryEntry // Can't make an interface field private I guess. public final _data:T; - public function _fetchData(id:String):Null; + // Can't make a static field required by an interface I guess. + // private static function _fetchData(id:String):Null; } diff --git a/source/funkin/data/README.md b/source/funkin/data/README.md new file mode 100644 index 000000000..58fa6fa59 --- /dev/null +++ b/source/funkin/data/README.md @@ -0,0 +1,21 @@ +# funkin.data + +Data structures are parsed using `json2object`, which uses macros to generate parser classes based on anonymous structures OR classes. + +Parsing errors will be returned in `parser.errors`. See `json2object.Error` for an enumeration of possible parsing errors. If an error occurred, `parser.value` will be null. + +The properties of these anonymous structures can have their behavior changed with annotations: + +- `@:optional`: The value is optional and will not throw a parsing error if it is not present in the JSON data. +- `@:default("test")`: If the value is optional, this value will be used instead of `null`. Replace `"test"` with a value of the property's type. +- `@:default(auto)`: If the value is an anonymous structure with `json2object` annotations, each field will be initialized to its default value. +- `@:jignored`: This value will be ignored by the parser. Their presence will not be checked in the JSON data and their values will not be parsed. +- `@:alias`: Choose the name the value will use in the JSON data to be separate from the property name. Useful if the desired name is a reserved word like `public`. +- `@:jcustomparse`: Provide a custom function for parsing from a JSON string into a value. + - Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property. + - `hxjsonast.Json` contains a `pos` and a `value`, with `value` being an enum: https://nadako.github.io/hxjsonast/hxjsonast/JsonValue.html + - Errors thrown in this function will cause a parsing error (`CustomFunctionException`) along with a position! + - Make sure to provide the FULLY QUALIFIED path to the custom function. +- `@:jcustomwrite`: Provide a custom function for serializing the property into a string for storage as JSON. + - Functions must be of the signature `(T) -> String`, where `T` is the type of the property. + diff --git a/source/funkin/data/event/SongEventData.hx b/source/funkin/data/event/SongEventData.hx new file mode 100644 index 000000000..831a53fbd --- /dev/null +++ b/source/funkin/data/event/SongEventData.hx @@ -0,0 +1,236 @@ +package funkin.data.event; + +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; + +/** + * This class statically handles the parsing of internal and scripted song event handlers. + */ +class SongEventParser +{ + /** + * Every built-in event class must be added to this list. + * Thankfully, with the power of `SongEventMacro`, this is done automatically. + */ + static final BUILTIN_EVENTS:List> = ClassMacro.listSubclassesOf(SongEvent); + + /** + * Map of internal handlers for song events. + * These may be either `ScriptedSongEvents` or built-in classes extending `SongEvent`. + */ + static final eventCache:Map = new Map(); + + public static function loadEventCache():Void + { + clearEventCache(); + + // + // BASE GAME EVENTS + // + registerBaseEvents(); + registerScriptedEvents(); + } + + static function registerBaseEvents() + { + trace('Instantiating ${BUILTIN_EVENTS.length} built-in song events...'); + for (eventCls in BUILTIN_EVENTS) + { + var eventClsName:String = Type.getClassName(eventCls); + if (eventClsName == 'funkin.play.event.SongEvent' || eventClsName == 'funkin.play.event.ScriptedSongEvent') continue; + + var event:SongEvent = Type.createInstance(eventCls, ["UNKNOWN"]); + + if (event != null) + { + trace(' Loaded built-in song event: (${event.id})'); + eventCache.set(event.id, event); + } + else + { + trace(' Failed to load built-in song event: ${Type.getClassName(eventCls)}'); + } + } + } + + static function registerScriptedEvents() + { + var scriptedEventClassNames:Array = ScriptedSongEvent.listScriptClasses(); + if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0) return; + + trace('Instantiating ${scriptedEventClassNames.length} scripted song events...'); + for (eventCls in scriptedEventClassNames) + { + var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN"); + + if (event != null) + { + trace(' Loaded scripted song event: ${event.id}'); + eventCache.set(event.id, event); + } + else + { + trace(' Failed to instantiate scripted song event class: ${eventCls}'); + } + } + } + + public static function listEventIds():Array + { + return eventCache.keys().array(); + } + + public static function listEvents():Array + { + return eventCache.values(); + } + + public static function getEvent(id:String):SongEvent + { + return eventCache.get(id); + } + + public static function getEventSchema(id:String):SongEventSchema + { + var event:SongEvent = getEvent(id); + if (event == null) return null; + + return event.getEventSchema(); + } + + static function clearEventCache() + { + eventCache.clear(); + } + + public static function handleEvent(data:SongEventData):Void + { + var eventType:String = data.event; + var eventHandler:SongEvent = eventCache.get(eventType); + + if (eventHandler != null) + { + eventHandler.handleEvent(data); + } + else + { + trace('WARNING: No event handler for event with id: ${eventType}'); + } + + data.activated = true; + } + + public static inline function handleEvents(events:Array):Void + { + for (event in events) + { + handleEvent(event); + } + } + + /** + * Given a list of song events and the current timestamp, + * return a list of events that should be handled. + */ + public static function queryEvents(events:Array, currentTime:Float):Array + { + return events.filter(function(event:SongEventData):Bool { + // If the event is already activated, don't activate it again. + if (event.activated) return false; + + // If the event is in the future, don't activate it. + if (event.time > currentTime) return false; + + return true; + }); + } + + /** + * Reset activation of all the provided events. + */ + public static function resetEvents(events:Array):Void + { + for (event in events) + { + event.activated = false; + // TODO: Add an onReset() method to SongEvent? + } + } +} + +enum abstract SongEventFieldType(String) from String to String +{ + /** + * The STRING type will display as a text field. + */ + var STRING = "string"; + + /** + * The INTEGER type will display as a text field that only accepts numbers. + */ + var INTEGER = "integer"; + + /** + * The FLOAT type will display as a text field that only accepts numbers. + */ + var FLOAT = "float"; + + /** + * The BOOL type will display as a checkbox. + */ + var BOOL = "bool"; + + /** + * The ENUM type will display as a dropdown. + * Make sure to specify the `keys` field in the schema. + */ + var ENUM = "enum"; +} + +typedef SongEventSchemaField = +{ + /** + * The name of the property as it should be saved in the event data. + */ + name:String, + + /** + * The title of the field to display in the UI. + */ + title:String, + + /** + * The type of the field. + */ + type:SongEventFieldType, + + /** + * Used for ENUM values. + * The key is the display name and the value is the actual value. + */ + ?keys:Map, + /** + * Used for INTEGER and FLOAT values. + * The minimum value that can be entered. + */ + ?min:Float, + /** + * Used for INTEGER and FLOAT values. + * The maximum value that can be entered. + */ + ?max:Float, + /** + * Used for INTEGER and FLOAT values. + * The step value that will be used when incrementing/decrementing the value. + */ + ?step:Float, + /** + * An optional default value for the field. + */ + ?defaultValue:Dynamic, +} + +typedef SongEventSchema = Array; diff --git a/source/funkin/data/level/LevelData.hx b/source/funkin/data/level/LevelData.hx index 0ba26354a..843389cae 100644 --- a/source/funkin/data/level/LevelData.hx +++ b/source/funkin/data/level/LevelData.hx @@ -24,6 +24,7 @@ typedef LevelData = /** * The graphic for the level, as seen in the scrolling list. */ + @:jcustomparse(funkin.data.DataParse.stringNotEmpty) var titleAsset:String; @:default([]) @@ -40,6 +41,7 @@ typedef LevelPropData = /** * The image to use for the prop. May optionally be a sprite sheet. */ + // @:jcustomparse(funkin.data.DataParse.stringNotEmpty) var assetPath:String; /** diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx index 36ce883ea..d135e1241 100644 --- a/source/funkin/data/level/LevelRegistry.hx +++ b/source/funkin/data/level/LevelRegistry.hx @@ -30,17 +30,18 @@ class LevelRegistry extends BaseRegistry // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser(); - var jsonStr:String = loadEntryFile(id); - parser.fromJson(jsonStr); + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { - trace('[${registryId}] Failed to parse entry data: ${id}'); - for (error in parser.errors) - { - trace(error); - } + printErrors(parser.errors, id); return null; } return parser.value; diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx index 65f6f627a..bb594bca4 100644 --- a/source/funkin/data/notestyle/NoteStyleRegistry.hx +++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx @@ -34,22 +34,21 @@ class NoteStyleRegistry extends BaseRegistry */ public function parseEntryData(id:String):Null { - if (id == null) id = DEFAULT_NOTE_STYLE_ID; - // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser(); - var jsonStr:String = loadEntryFile(id); - parser.fromJson(jsonStr); + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { - trace('[${registryId}] Failed to parse entry data: ${id}'); - for (error in parser.errors) - { - trace(error); - } + printErrors(parser.errors, id); return null; } return parser.value; diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx new file mode 100644 index 000000000..2e98b9c0a --- /dev/null +++ b/source/funkin/data/song/SongData.hx @@ -0,0 +1,649 @@ +package funkin.data.song; + +import flixel.util.typeLimit.OneOfTwo; +import funkin.play.song.SongMigrator; +import funkin.play.song.SongValidator; +import funkin.data.song.SongRegistry; +import thx.semver.Version; + +class SongMetadata +{ + /** + * A semantic versioning string for the song data format. + * + */ + // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION) + public var version:Version; + + @:default("Unknown") + public var songName:String; + + @:default("Unknown") + public var artist:String; + + @:optional + @:default(96) + public var divisions:Null; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + /** + * Data relating to the song's gameplay. + */ + public var playData:SongPlayData; + + // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS) + public var timeFormat:SongTimeFormat; + + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES) + public var timeChanges:Array; + + /** + * Defaults to `default` or `''`. Populated later. + */ + @:jignored + public var variation:String = 'default'; + + public function new(songName:String, artist:String, variation:String = 'default') + { + this.version = SongMigrator.CHART_VERSION; + this.songName = songName; + this.artist = artist; + this.timeFormat = 'ms'; + this.divisions = null; + this.timeChanges = [new SongTimeChange(0, 100)]; + this.looped = false; + this.playData = + { + songVariations: [], + difficulties: ['normal'], + + playableChars: ['bf' => new SongPlayableChar('gf', 'dad')], + + stage: 'mainStage', + noteSkin: 'Normal' + }; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = variation; + } + + public function clone(?newVariation:String = null):SongMetadata + { + var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.timeChanges = this.timeChanges; + result.looped = this.looped; + result.playData = this.playData; + result.generatedBy = this.generatedBy; + + return result; + } +} + +enum abstract SongTimeFormat(String) from String to String +{ + var TICKS = 'ticks'; + var FLOAT = 'float'; + var MILLISECONDS = 'ms'; +} + +class SongTimeChange +{ + public static final DEFAULT_SONGTIMECHANGE:SongTimeChange = new SongTimeChange(0, 100); + + public static final DEFAULT_SONGTIMECHANGES:Array = [DEFAULT_SONGTIMECHANGE]; + + static final DEFAULT_BEAT_TUPLETS:Array = [4, 4, 4, 4]; + static final DEFAULT_BEAT_TIME:Null = null; // Later, null gets detected and recalculated. + + /** + * Timestamp in specified `timeFormat`. + */ + @:alias("t") + public var timeStamp:Float; + + /** + * Time in beats (int). The game will calculate further beat values based on this one, + * so it can do it in a simple linear fashion. + */ + @:optional + @:alias("b") + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_BEAT_TIME) + public var beatTime:Null; + + /** + * Quarter notes per minute (float). Cannot be empty in the first element of the list, + * but otherwise it's optional, and defaults to the value of the previous element. + */ + @:alias("bpm") + public var bpm:Float; + + /** + * Time signature numerator (int). Optional, defaults to 4. + */ + @:default(4) + @:optional + @:alias("n") + public var timeSignatureNum:Int; + + /** + * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. + */ + @:default(4) + @:optional + @:alias("d") + public var timeSignatureDen:Int; + + /** + * Beat tuplets (Array or int). This defines how many steps each beat is divided into. + * It can either be an array of length `n` (see above) or a single integer number. + * Optional, defaults to `[4]`. + */ + @:optional + @:alias("bt") + public var beatTuplets:Array; + + public function new(timeStamp:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, ?beatTime:Float, ?beatTuplets:Array) + { + this.timeStamp = timeStamp; + this.bpm = bpm; + + this.timeSignatureNum = timeSignatureNum; + this.timeSignatureDen = timeSignatureDen; + + this.beatTime = beatTime == null ? DEFAULT_BEAT_TIME : beatTime; + this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets; + } +} + +/** + * Metadata for a song only used for the music. + * For example, the menu music. + */ +class SongMusicData +{ + /** + * A semantic versioning string for the song data format. + * + */ + // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION) + public var version:Version; + + @:default("Unknown") + public var songName:String; + + @:default("Unknown") + public var artist:String; + + @:optional + @:default(96) + public var divisions:Null; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS) + public var timeFormat:SongTimeFormat; + + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES) + public var timeChanges:Array; + + /** + * Defaults to `default` or `''`. Populated later. + */ + @:jignored + public var variation:String = 'default'; + + public function new(songName:String, artist:String, variation:String = 'default') + { + this.version = SongMigrator.CHART_VERSION; + this.songName = songName; + this.artist = artist; + this.timeFormat = 'ms'; + this.divisions = null; + this.timeChanges = [new SongTimeChange(0, 100)]; + this.looped = false; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = variation; + } + + public function clone(?newVariation:String = null):SongMusicData + { + var result:SongMusicData = new SongMusicData(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.timeChanges = this.timeChanges; + result.looped = this.looped; + result.generatedBy = this.generatedBy; + + return result; + } +} + +typedef SongPlayData = +{ + public var songVariations:Array; + public var difficulties:Array; + + /** + * Keys are the player characters and the values give info on what opponent/GF/inst to use. + */ + public var playableChars:Map; + + public var stage:String; + public var noteSkin:String; +} + +class SongPlayableChar +{ + @:alias('g') + @:optional + @:default('') + public var girlfriend:String = ''; + + @:alias('o') + @:optional + @:default('') + public var opponent:String = ''; + + @:alias('i') + @:optional + @:default('') + public var inst:String = ''; + + public function new(girlfriend:String = '', opponent:String = '', inst:String = '') + { + this.girlfriend = girlfriend; + this.opponent = opponent; + this.inst = inst; + } +} + +class SongChartData +{ + @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION) + public var version:Version; + + public var scrollSpeed:Map; + public var events:Array; + public var notes:Map>; + + @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + public function new(scrollSpeed:Map, events:Array, notes:Map>) + { + this.version = SongRegistry.SONG_CHART_DATA_VERSION; + + this.events = events; + this.notes = notes; + this.scrollSpeed = scrollSpeed; + + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + } + + public function getScrollSpeed(diff:String = 'default'):Float + { + var result:Float = this.scrollSpeed.get(diff); + + if (result == 0.0 && diff != 'default') return getScrollSpeed('default'); + + return (result == 0.0) ? 1.0 : result; + } + + public function setScrollSpeed(value:Float, diff:String = 'default'):Float + { + this.scrollSpeed.set(diff, value); + return value; + } + + public function getNotes(diff:String):Array + { + var result:Array = this.notes.get(diff); + + if (result == null && diff != 'normal') return getNotes('normal'); + + return (result == null) ? [] : result; + } + + public function setNotes(value:Array, diff:String):Array + { + this.notes.set(diff, value); + return value; + } + + public function getEvents():Array + { + return this.events; + } + + public function setEvents(value:Array):Array + { + return this.events = value; + } +} + +class SongEventData +{ + /** + * The timestamp of the event. The timestamp is in the format of the song's time format. + */ + @:alias("t") + public var time:Float; + + /** + * The kind of the event. + * Examples include "FocusCamera" and "PlayAnimation" + * Custom events can be added by scripts with the `ScriptedSongEvent` class. + */ + @:alias("e") + public var event:String; + + /** + * The data for the event. + * This can allow the event to include information used for custom behavior. + * Data type depends on the event kind. It can be anything that's JSON serializable. + */ + @:alias("v") + @:optional + @:jcustomparse(funkin.data.DataParse.dynamicValue) + public var value:Dynamic = null; + + /** + * Whether this event has been activated. + * This is only used internally by the game. It should not be serialized. + */ + @:jignored + public var activated:Bool = false; + + public function new(time:Float, event:String, value:Dynamic = null) + { + this.time = time; + this.event = event; + this.value = value; + } + + @:jignored + public var stepTime(get, never):Float; + + function get_stepTime():Float + { + return Conductor.getTimeInSteps(this.time); + } + + public inline function getDynamic(key:String):Null + { + return value == null ? null : Reflect.field(value, key); + } + + public inline function getBool(key:String):Null + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getInt(key:String):Null + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getFloat(key:String):Null + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getString(key:String):String + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getArray(key:String):Array + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getBoolArray(key:String):Array + { + return value == null ? null : cast Reflect.field(value, key); + } + + @:op(A == B) + public function op_equals(other:SongEventData):Bool + { + return this.time == other.time && this.event == other.event && this.value == other.value; + } + + @:op(A != B) + public function op_notEquals(other:SongEventData):Bool + { + return this.time != other.time || this.event != other.event || this.value != other.value; + } + + @:op(A > B) + public function op_greaterThan(other:SongEventData):Bool + { + return this.time > other.time; + } + + @:op(A < B) + public function op_lessThan(other:SongEventData):Bool + { + return this.time < other.time; + } + + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongEventData):Bool + { + return this.time >= other.time; + } + + @:op(A <= B) + public function op_lessThanOrEquals(other:SongEventData):Bool + { + return this.time <= other.time; + } +} + +class SongNoteData +{ + /** + * The timestamp of the note. The timestamp is in the format of the song's time format. + */ + @:alias("t") + public var time:Float; + + /** + * Data for the note. Represents the index on the strumline. + * 0 = left, 1 = down, 2 = up, 3 = right + * `floor(direction / strumlineSize)` specifies which strumline the note is on. + * 0 = player, 1 = opponent, etc. + */ + @:alias("d") + public var data:Int; + + /** + * Length of the note, if applicable. + * Defaults to 0 for single notes. + */ + @:alias("l") + @:default(0) + @:optional + public var length:Float; + + /** + * The kind of the note. + * This can allow the note to include information used for custom behavior. + * Defaults to blank or `"normal"`. + */ + @:alias("k") + @:default("normal") + @:optional + public var kind(get, default):String = ''; + + function get_kind():String + { + if (this.kind == null || this.kind == '') return 'normal'; + + return this.kind; + } + + public function new(time:Float, data:Int, length:Float = 0, kind:String = '') + { + this.time = time; + this.data = data; + this.length = length; + this.kind = kind; + } + + /** + * The timestamp of the note, in steps. + */ + @:jignored + public var stepTime(get, never):Float; + + function get_stepTime():Float + { + return Conductor.getTimeInSteps(this.time); + } + + /** + * The direction of the note, if applicable. + * Strips the strumline index from the data. + * + * 0 = left, 1 = down, 2 = up, 3 = right + */ + public inline function getDirection(strumlineSize:Int = 4):Int + { + return this.data % strumlineSize; + } + + public function getDirectionName(strumlineSize:Int = 4):String + { + switch (this.data % strumlineSize) + { + case 0: + return 'Left'; + case 1: + return 'Down'; + case 2: + return 'Up'; + case 3: + return 'Right'; + default: + return 'Unknown'; + } + } + + /** + * The strumline index of the note, if applicable. + * Strips the direction from the data. + * + * 0 = player, 1 = opponent, etc. + */ + public inline function getStrumlineIndex(strumlineSize:Int = 4):Int + { + return Math.floor(this.data / strumlineSize); + } + + /** + * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side). + * TODO: The name of this function is a little misleading; what about mines? + * @param strumlineSize Defaults to 4. + * @return True if it's Boyfriend's note. + */ + public inline function getMustHitNote(strumlineSize:Int = 4):Bool + { + return getStrumlineIndex(strumlineSize) == 0; + } + + /** + * If this is a hold note, this is the length of the hold note in steps. + * @default 0 (not a hold note) + */ + public var stepLength(get, set):Float; + + function get_stepLength():Float + { + return Conductor.getTimeInSteps(this.time + this.length) - this.stepTime; + } + + function set_stepLength(value:Float):Float + { + return this.length = Conductor.getStepTimeInMs(value) - this.time; + } + + @:jignored + public var isHoldNote(get, never):Bool; + + public function get_isHoldNote():Bool + { + return this.length > 0; + } + + @:op(A == B) + public function op_equals(other:SongNoteData):Bool + { + if (this.kind == '') + { + if (other.kind != '' && other.kind != 'normal') return false; + } + else + { + if (other.kind == '' || other.kind != this.kind) return false; + } + + return this.time == other.time && this.data == other.data && this.length == other.length; + } + + @:op(A != B) + public function op_notEquals(other:SongNoteData):Bool + { + if (this.kind == '') + { + if (other.kind != '' && other.kind != 'normal') return true; + } + else + { + if (other.kind == '' || other.kind != this.kind) return true; + } + + return this.time != other.time || this.data != other.data || this.length != other.length; + } + + @:op(A > B) + public function op_greaterThan(other:SongNoteData):Bool + { + return this.time > other.time; + } + + @:op(A < B) + public function op_lessThan(other:SongNoteData):Bool + { + return this.time < other.time; + } + + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongNoteData):Bool + { + return this.time >= other.time; + } + + @:op(A <= B) + public function op_lessThanOrEquals(other:SongNoteData):Bool + { + return this.time <= other.time; + } +} diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx new file mode 100644 index 000000000..d15a2b19a --- /dev/null +++ b/source/funkin/data/song/SongDataUtils.hx @@ -0,0 +1,232 @@ +package funkin.data.song; + +import flixel.util.FlxSort; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.util.ClipboardUtil; +import funkin.util.SerializerUtil; + +using Lambda; + +class SongDataUtils +{ + /** + * Given an array of SongNoteData objects, return a new array of SongNoteData objects + * whose timestamps are shifted by the given amount. + * Does not mutate the original array. + * + * @param notes The notes to modify. + * @param offset The time difference to apply in milliseconds. + */ + public static function offsetSongNoteData(notes:Array, offset:Int):Array + { + return notes.map(function(note:SongNoteData):SongNoteData { + return new SongNoteData(note.time + offset, note.data, note.length, note.kind); + }); + } + + /** + * Given an array of SongEventData objects, return a new array of SongEventData objects + * whose timestamps are shifted by the given amount. + * Does not mutate the original array. + * + * @param events The events to modify. + * @param offset The time difference to apply in milliseconds. + */ + public static function offsetSongEventData(events:Array, offset:Int):Array + { + return events.map(function(event:SongEventData):SongEventData { + return new SongEventData(event.time + offset, event.event, event.value); + }); + } + + /** + * Return a new array without a certain subset of notes from an array of SongNoteData objects. + * Does not mutate the original array. + * + * @param notes The array of notes to be subtracted from. + * @param subtrahend The notes to remove from the `notes` array. Yes, subtrahend is a real word. + */ + public static function subtractNotes(notes:Array, subtrahend:Array) + { + if (notes.length == 0 || subtrahend.length == 0) return notes; + + var result = notes.filter(function(note:SongNoteData):Bool { + for (x in subtrahend) + // SongNoteData's == operation has been overridden so that this will work. + if (x == note) return false; + + return true; + }); + + return result; + } + + /** + * Return a new array without a certain subset of events from an array of SongEventData objects. + * Does not mutate the original array. + * + * @param events The array of events to be subtracted from. + * @param subtrahend The events to remove from the `events` array. Yes, subtrahend is a real word. + */ + public static function subtractEvents(events:Array, subtrahend:Array) + { + if (events.length == 0 || subtrahend.length == 0) return events; + + return events.filter(function(event:SongEventData):Bool { + // SongEventData's == operation has been overridden so that this will work. + return !subtrahend.has(event); + }); + } + + /** + * Create an array of notes whose note data is flipped (player becomes opponent and vice versa) + * Does not mutate the original array. + */ + public static function flipNotes(notes:Array, ?strumlineSize:Int = 4):Array + { + return notes.map(function(note:SongNoteData):SongNoteData { + var newData = note.data; + + if (newData < strumlineSize) newData += strumlineSize; + else + newData -= strumlineSize; + + return new SongNoteData(note.time, newData, note.length, note.kind); + }); + } + + /** + * Prepare an array of notes to be used as the clipboard data. + * + * Offset the provided array of notes such that the first note is at 0 milliseconds. + */ + public static function buildNoteClipboard(notes:Array, ?timeOffset:Int = null):Array + { + if (notes.length == 0) return notes; + if (timeOffset == null) timeOffset = -Std.int(notes[0].time); + return offsetSongNoteData(sortNotes(notes), timeOffset); + } + + /** + * Prepare an array of events to be used as the clipboard data. + * + * Offset the provided array of events such that the first event is at 0 milliseconds. + */ + public static function buildEventClipboard(events:Array, ?timeOffset:Int = null):Array + { + if (events.length == 0) return events; + if (timeOffset == null) timeOffset = -Std.int(events[0].time); + return offsetSongEventData(sortEvents(events), timeOffset); + } + + /** + * Sort an array of notes by strum time. + */ + public static function sortNotes(notes:Array, desc:Bool = false):Array + { + // TODO: Modifies the array in place. Is this okay? + notes.sort(function(a:SongNoteData, b:SongNoteData):Int { + return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time); + }); + return notes; + } + + /** + * Sort an array of events by strum time. + */ + public static function sortEvents(events:Array, desc:Bool = false):Array + { + // TODO: Modifies the array in place. Is this okay? + events.sort(function(a:SongEventData, b:SongEventData):Int { + return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time); + }); + return events; + } + + /** + * Serialize note and event data and write it to the clipboard. + */ + public static function writeItemsToClipboard(data:SongClipboardItems):Void + { + var dataString = SerializerUtil.toJSON(data); + + ClipboardUtil.setClipboard(dataString); + + trace('Wrote ' + data.notes.length + ' notes and ' + data.events.length + ' events to clipboard.'); + + trace(dataString); + } + + /** + * Read an array of note data from the clipboard and deserialize it. + */ + public static function readItemsFromClipboard():SongClipboardItems + { + var notesString = ClipboardUtil.getClipboard(); + + trace('Read ${notesString.length} characters from clipboard.'); + + var data:SongClipboardItems = notesString.parseJSON(); + + if (data == null) + { + trace('Failed to parse notes from clipboard.'); + return { + notes: [], + events: [] + }; + } + else + { + trace('Parsed ' + data.notes.length + ' notes and ' + data.events.length + ' from clipboard.'); + return data; + } + } + + /** + * Filter a list of notes to only include notes that are within the given time range. + */ + public static function getNotesInTimeRange(notes:Array, start:Float, end:Float):Array + { + return notes.filter(function(note:SongNoteData):Bool { + return note.time >= start && note.time <= end; + }); + } + + /** + * Filter a list of events to only include events that are within the given time range. + */ + public static function getEventsInTimeRange(events:Array, start:Float, end:Float):Array + { + return events.filter(function(event:SongEventData):Bool { + return event.time >= start && event.time <= end; + }); + } + + /** + * Filter a list of notes to only include notes whose data is within the given range. + */ + public static function getNotesInDataRange(notes:Array, start:Int, end:Int):Array + { + return notes.filter(function(note:SongNoteData):Bool { + return note.data >= start && note.data <= end; + }); + } + + /** + * Filter a list of notes to only include notes whose data is one of the given values. + */ + public static function getNotesWithData(notes:Array, data:Array):Array + { + return notes.filter(function(note:SongNoteData):Bool { + return data.indexOf(note.data) != -1; + }); + } +} + +typedef SongClipboardItems = +{ + notes:Array, + events:Array +} diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx new file mode 100644 index 000000000..e21c74a1f --- /dev/null +++ b/source/funkin/data/song/SongRegistry.hx @@ -0,0 +1,262 @@ +package funkin.data.song; + +import funkin.data.song.SongData; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; +import funkin.play.song.ScriptedSong; +import funkin.play.song.Song; +import funkin.util.assets.DataAssets; +import funkin.util.VersionUtil; + +class SongRegistry extends BaseRegistry +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final SONG_METADATA_VERSION:thx.semver.Version = "2.0.0"; + + public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + + public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0"; + + public static final SONG_CHART_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + + public static var DEFAULT_GENERATEDBY(get, null):String; + + static function get_DEFAULT_GENERATEDBY():String + { + return '${Constants.TITLE} - ${Constants.VERSION}'; + } + + public static final instance:SongRegistry = new SongRegistry(); + + public function new() + { + super('SONG', 'songs', SONG_METADATA_VERSION_RULE); + } + + public override function loadEntries():Void + { + clearEntries(); + + // + // SCRIPTED ENTRIES + // + var scriptedEntryClassNames:Array = getScriptedClassNames(); + log('Registering ${scriptedEntryClassNames.length} scripted entries...'); + + for (entryCls in scriptedEntryClassNames) + { + var entry:Song = createScriptedEntry(entryCls); + + if (entry != null) + { + log('Successfully created scripted entry (${entryCls} = ${entry.id})'); + entries.set(entry.id, entry); + } + else + { + log('Failed to create scripted entry (${entryCls})'); + } + } + + // + // UNSCRIPTED ENTRIES + // + var entryIdList:Array = DataAssets.listDataFilesInPath('songs/', '-metadata.json').map(function(songDataPath:String):String { + return songDataPath.split('/')[0]; + }); + var unscriptedEntryIds:Array = entryIdList.filter(function(entryId:String):Bool { + return !entries.exists(entryId); + }); + log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...'); + for (entryId in unscriptedEntryIds) + { + try + { + var entry:Song = createEntry(entryId); + if (entry != null) + { + trace(' Loaded entry data: ${entry}'); + entries.set(entry.id, entry); + } + } + catch (e:Dynamic) + { + // Print the error. + trace(' Failed to load entry data: ${entryId}'); + trace(e); + continue; + } + } + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null + { + return parseEntryMetadata(id); + } + + public function parseEntryMetadata(id:String, variation:String = ""):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + + var parser = new json2object.JsonParser(); + switch (loadEntryMetadataFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryMetadataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null + { + // If a version rule is not specified, do not check against it. + if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE)) + { + return parseEntryMetadata(id); + } + else + { + throw '[${registryId}] Metadata entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.'; + } + } + + public function parseMusicData(id:String, variation:String = ""):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + + var parser = new json2object.JsonParser(); + switch (loadMusicDataFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryChartData(id:String, variation:String = ''):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser(); + + switch (loadEntryChartFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryChartDataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null + { + // If a version rule is not specified, do not check against it. + if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE)) + { + return parseEntryChartData(id, variation); + } + else + { + throw '[${registryId}] Chart entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + } + } + + function createScriptedEntry(clsName:String):Song + { + return ScriptedSong.init(clsName, "unknown"); + } + + function getScriptedClassNames():Array + { + return ScriptedSong.listScriptClasses(); + } + + function loadEntryMetadataFile(id:String, variation:String = ''):BaseRegistry.JsonFile + { + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-metadata'); + var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + function loadMusicDataFile(id:String, variation:String = ''):BaseRegistry.JsonFile + { + var entryFilePath:String = Paths.file('music/$id/$id${variation == '' ? '' : '-$variation'}-metadata.json'); + var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + function loadEntryChartFile(id:String, variation:String = ''):BaseRegistry.JsonFile + { + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-chart'); + var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null + { + var entryStr:String = loadEntryMetadataFile(id, variation).contents; + var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + return entryVersion; + } + + public function fetchEntryChartVersion(id:String, variation:String = ''):Null + { + var entryStr:String = loadEntryChartFile(id, variation).contents; + var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + return entryVersion; + } + + /** + * A list of all the story weeks from the base game, in order. + * TODO: Should this be hardcoded? + */ + public function listBaseGameSongIds():Array + { + return [ + "tutorial", "bopeebo", "fresh", "dadbattle", "spookeez", "south", "monster", "pico", "philly-nice", "blammed", "satin-panties", "high", "milf", "cocoa", + "eggnog", "winter-horrorland", "senpai", "roses", "thorns", "ugh", "guns", "stress", "darnell", "lit-up", "2hot", "blazin" + ]; + } + + /** + * A list of all installed story weeks that are not from the base game. + */ + public function listModdedSongIds():Array + { + return listEntryIds().filter(function(id:String):Bool { + return listBaseGameSongIds().indexOf(id) == -1; + }); + } +} From dfedaa8838dabf61d42401504738c0e5d0884efe Mon Sep 17 00:00:00 2001 From: EliteMasterEric Date: Fri, 8 Sep 2023 17:46:10 -0400 Subject: [PATCH 3/4] Updated and revised unit tests (including new SongRegistry) --- .../preload/data/notestyles/funkin.json | 4 +- .../bopeebo-swapped-chart.json | 15 + .../bopeebo-swapped-metadata.json | 1738 +++++++++++++++++ .../data/songs/bopeebo/bopeebo-chart.json | 1738 +++++++++++++++++ .../data/songs/bopeebo/bopeebo-metadata.json | 15 + tests/unit/project.xml | 8 +- tests/unit/source/FunkinAssert.hx | 50 + tests/unit/source/MockTest.hx | 8 +- tests/unit/source/funkin/ConductorTest.hx | 61 +- .../source/funkin/data/BaseRegistryTest.hx | 56 +- .../funkin/data/level/LevelRegistryTest.hx | 6 +- .../data/notestyle/NoteStyleRegistryTest.hx | 2 +- .../funkin/data/song/SongRegistryTest.hx | 133 ++ .../play/notes/notestyle/NoteStyleTest.hx | 139 +- tests/unit/source/funkin/util/SortUtilTest.hx | 2 +- .../util/assets/FlxAnimationUtilTest.hx | 8 +- 16 files changed, 3882 insertions(+), 101 deletions(-) create mode 100644 tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json create mode 100644 tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json create mode 100644 tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json create mode 100644 tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json create mode 100644 tests/unit/source/funkin/data/song/SongRegistryTest.hx 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 @@ + + + - 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 = []; + + 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 = 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 = 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 = [ - { - 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 = [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 = [ - { - 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 = [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 = [ - { - 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 return 'MyType($id)'; } - public function _fetchData(id:String):Null + static function _fetchData(id:String):Null { return MyTypeRegistry.instance.parseEntryDataWithMigration(id, MyTypeRegistry.instance.fetchEntryVersion(id)); } @@ -153,17 +156,18 @@ class MyTypeRegistry extends BaseRegistry // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser(); - var jsonStr:String = loadEntryFile(id); - parser.fromJson(jsonStr); + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { - trace('[${registryId}] Failed to parse entry data: ${id}'); - for (error in parser.errors) - { - trace(error); - } + printErrors(parser.errors, id); return null; } return parser.value; @@ -177,31 +181,33 @@ class MyTypeRegistry extends BaseRegistry // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser(); - var jsonStr:String = loadEntryFile(id); - parser.fromJson(jsonStr); + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { - trace('[${registryId}] Failed to parse entry data: ${id}'); - for (error in parser.errors) - { - trace(error); - } + printErrors(parser.errors, id); return null; } 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 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = [ From f4bc682ea18f1bebb9d4b599782afa3aa14c4e7c Mon Sep 17 00:00:00 2001 From: EliteMasterEric Date: Fri, 8 Sep 2023 17:46:44 -0400 Subject: [PATCH 4/4] Remove funkin.play.song.SongData and refactor app to match. --- docs/troubleshooting.md | 6 + source/funkin/Conductor.hx | 2 +- source/funkin/DialogueBox.hx | 12 +- source/funkin/FreeplayState.hx | 5 +- source/funkin/InitState.hx | 10 +- source/funkin/LoadingState.hx | 2 +- source/funkin/PauseSubState.hx | 4 +- source/funkin/modding/PolymodHandler.hx | 7 +- source/funkin/modding/events/ScriptEvent.hx | 6 +- source/funkin/play/PlayState.hx | 22 +- source/funkin/play/ResultState.hx | 2 +- .../funkin/play/event/FocusCameraSongEvent.hx | 9 +- .../play/event/PlayAnimationSongEvent.hx | 9 +- .../play/event/SetCameraBopSongEvent.hx | 9 +- source/funkin/play/event/SongEvent.hx | 4 +- source/funkin/play/event/SongEventData.hx | 235 ---- .../funkin/play/event/ZoomCameraSongEvent.hx | 12 +- source/funkin/play/notes/NoteSprite.hx | 2 +- source/funkin/play/notes/Strumline.hx | 2 +- source/funkin/play/notes/SustainTrail.hx | 2 +- .../funkin/play/notes/notestyle/NoteStyle.hx | 13 +- source/funkin/play/song/Song.hx | 163 ++- source/funkin/play/song/SongData.hx | 1021 ----------------- source/funkin/play/song/SongDataUtils.hx | 232 ---- source/funkin/play/song/SongMigrator.hx | 14 +- source/funkin/play/song/SongSerializer.hx | 4 +- source/funkin/play/song/SongValidator.hx | 20 +- .../ui/debug/charting/ChartEditorCommand.hx | 6 +- .../charting/ChartEditorDialogHandler.hx | 74 +- .../debug/charting/ChartEditorEventSprite.hx | 4 +- .../charting/ChartEditorHoldNoteSprite.hx | 2 +- .../debug/charting/ChartEditorNotePreview.hx | 4 +- .../debug/charting/ChartEditorNoteSprite.hx | 2 +- .../ui/debug/charting/ChartEditorState.hx | 23 +- .../charting/ChartEditorToolboxHandler.hx | 10 +- source/funkin/ui/story/Level.hx | 24 +- source/funkin/ui/story/StoryMenuState.hx | 13 +- source/funkin/ui/title/TitleState.hx | 12 +- source/funkin/util/SortUtil.hx | 11 + source/funkin/util/tools/IteratorTools.hx | 18 + 40 files changed, 339 insertions(+), 1693 deletions(-) create mode 100644 docs/troubleshooting.md delete mode 100644 source/funkin/play/event/SongEventData.hx delete mode 100644 source/funkin/play/song/SongData.hx delete mode 100644 source/funkin/play/song/SongDataUtils.hx 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/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index 9bd668b69..bd50b556c 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -4,7 +4,7 @@ import funkin.util.Constants; import flixel.util.FlxSignal; import flixel.math.FlxMath; import funkin.play.song.Song.SongDifficulty; -import funkin.play.song.SongData.SongTimeChange; +import funkin.data.song.SongData.SongTimeChange; /** * A core class which handles musical timing throughout the game, diff --git a/source/funkin/DialogueBox.hx b/source/funkin/DialogueBox.hx index 342fcba10..68d330dbe 100644 --- a/source/funkin/DialogueBox.hx +++ b/source/funkin/DialogueBox.hx @@ -37,7 +37,7 @@ class DialogueBox extends FlxSpriteGroup { super(); - switch (PlayState.instance.currentSong.songId.toLowerCase()) + switch (PlayState.instance.currentSong.id.toLowerCase()) { case 'senpai': FlxG.sound.playMusic(Paths.music('Lunchbox'), 0); @@ -78,7 +78,7 @@ class DialogueBox extends FlxSpriteGroup box = new FlxSprite(-20, 45); var hasDialog:Bool = false; - switch (PlayState.instance.currentSong.songId.toLowerCase()) + switch (PlayState.instance.currentSong.id.toLowerCase()) { case 'senpai': hasDialog = true; @@ -150,8 +150,8 @@ class DialogueBox extends FlxSpriteGroup override function update(elapsed:Float):Void { // HARD CODING CUZ IM STUPDI - if (PlayState.instance.currentSong.songId.toLowerCase() == 'roses') portraitLeft.visible = false; - if (PlayState.instance.currentSong.songId.toLowerCase() == 'thorns') + if (PlayState.instance.currentSong.id.toLowerCase() == 'roses') portraitLeft.visible = false; + if (PlayState.instance.currentSong.id.toLowerCase() == 'thorns') { portraitLeft.color = FlxColor.BLACK; swagDialogue.color = FlxColor.WHITE; @@ -187,8 +187,8 @@ class DialogueBox extends FlxSpriteGroup { isEnding = true; - if (PlayState.instance.currentSong.songId.toLowerCase() == 'senpai' - || PlayState.instance.currentSong.songId.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0); + if (PlayState.instance.currentSong.id.toLowerCase() == 'senpai' + || PlayState.instance.currentSong.id.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0); new FlxTimer().start(0.2, function(tmr:FlxTimer) { box.alpha -= 1 / 5; diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx index c31e8c77b..6cd353233 100644 --- a/source/funkin/FreeplayState.hx +++ b/source/funkin/FreeplayState.hx @@ -20,6 +20,7 @@ import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxColor; +import funkin.data.song.SongRegistry; import flixel.util.FlxSpriteUtil; import flixel.util.FlxTimer; import funkin.Controls.Control; @@ -30,7 +31,6 @@ import funkin.freeplayStuff.LetterSort; import funkin.freeplayStuff.SongMenuItem; import funkin.play.HealthIcon; import funkin.play.PlayState; -import funkin.play.song.SongData.SongDataParser; import funkin.shaderslmfao.AngleMask; import funkin.shaderslmfao.PureColor; import funkin.shaderslmfao.StrokeShader; @@ -843,7 +843,8 @@ class FreeplayState extends MusicBeatSubState }*/ PlayStatePlaylist.isStoryMode = false; - var targetSong:Song = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase()); + var songId:String = songs[curSelected].songName.toLowerCase(); + var targetSong:Song = SongRegistry.instance.fetchEntry(songId); var targetDifficulty:String = switch (curDifficulty) { case 0: diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index 82a357ae9..e7060abd7 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -17,11 +17,11 @@ import funkin.play.PlayStatePlaylist; import openfl.display.BitmapData; import funkin.data.level.LevelRegistry; import funkin.data.notestyle.NoteStyleRegistry; -import funkin.play.event.SongEventData.SongEventParser; +import funkin.data.event.SongEventData.SongEventParser; import funkin.play.cutscene.dialogue.ConversationDataParser; import funkin.play.cutscene.dialogue.DialogueBoxDataParser; import funkin.play.cutscene.dialogue.SpeakerDataParser; -import funkin.play.song.SongData.SongDataParser; +import funkin.data.song.SongRegistry; import funkin.play.stage.StageData.StageDataParser; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.modding.module.ModuleHandler; @@ -197,13 +197,13 @@ class InitState extends FlxState // NOTE: Registries and data parsers must be imported and not referenced with fully qualified names, // to ensure build macros work properly. + SongRegistry.instance.loadEntries(); LevelRegistry.instance.loadEntries(); NoteStyleRegistry.instance.loadEntries(); SongEventParser.loadEventCache(); ConversationDataParser.loadConversationCache(); DialogueBoxDataParser.loadDialogueBoxCache(); SpeakerDataParser.loadSpeakerCache(); - SongDataParser.loadSongCache(); StageDataParser.loadStageCache(); CharacterDataParser.loadCharacterCache(); ModuleHandler.buildModuleCallbacks(); @@ -276,7 +276,7 @@ class InitState extends FlxState */ function startSong(songId:String, difficultyId:String = 'normal'):Void { - var songData:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId); + var songData:funkin.play.song.Song = funkin.data.song.SongRegistry.instance.fetchEntry(songId); if (songData == null) { @@ -312,7 +312,7 @@ class InitState extends FlxState var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift(); - var targetSong:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(targetSongId); + var targetSong:funkin.play.song.Song = SongRegistry.instance.fetchEntry(targetSongId); LoadingState.loadAndSwitchState(new funkin.play.PlayState( { diff --git a/source/funkin/LoadingState.hx b/source/funkin/LoadingState.hx index 3ec2e1005..216d9ba74 100644 --- a/source/funkin/LoadingState.hx +++ b/source/funkin/LoadingState.hx @@ -159,7 +159,7 @@ class LoadingState extends MusicBeatState static function getSongPath():String { - return Paths.inst(PlayState.instance.currentSong.songId); + return Paths.inst(PlayState.instance.currentSong.id); } inline static public function loadAndSwitchState(nextState:FlxState, shouldStopMusic = false):Void diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx index 791a4bb9a..f93e5a450 100644 --- a/source/funkin/PauseSubState.hx +++ b/source/funkin/PauseSubState.hx @@ -10,7 +10,7 @@ import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxColor; import funkin.play.PlayState; -import funkin.play.song.SongData.SongDataParser; +import funkin.data.song.SongRegistry; class PauseSubState extends MusicBeatSubState { @@ -197,7 +197,7 @@ class PauseSubState extends MusicBeatSubState regenMenu(); case 'EASY' | 'NORMAL' | 'HARD' | 'ERECT': - PlayState.instance.currentSong = SongDataParser.fetchSong(PlayState.instance.currentSong.songId.toLowerCase()); + PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase()); PlayState.instance.currentDifficulty = daSelected.toLowerCase(); diff --git a/source/funkin/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 ed82d6e99..b20b34c65 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/SongEventData.hx b/source/funkin/play/event/SongEventData.hx deleted file mode 100644 index 8c157b52a..000000000 --- a/source/funkin/play/event/SongEventData.hx +++ /dev/null @@ -1,235 +0,0 @@ -package funkin.play.event; - -import funkin.play.event.SongEventData.SongEventSchema; -import funkin.play.song.SongData.SongEventData; -import funkin.util.macro.ClassMacro; -import funkin.play.event.ScriptedSongEvent; - -/** - * This class statically handles the parsing of internal and scripted song event handlers. - */ -class SongEventParser -{ - /** - * Every built-in event class must be added to this list. - * Thankfully, with the power of `SongEventMacro`, this is done automatically. - */ - static final BUILTIN_EVENTS:List> = ClassMacro.listSubclassesOf(SongEvent); - - /** - * Map of internal handlers for song events. - * These may be either `ScriptedSongEvents` or built-in classes extending `SongEvent`. - */ - static final eventCache:Map = new Map(); - - public static function loadEventCache():Void - { - clearEventCache(); - - // - // BASE GAME EVENTS - // - registerBaseEvents(); - registerScriptedEvents(); - } - - static function registerBaseEvents() - { - trace('Instantiating ${BUILTIN_EVENTS.length} built-in song events...'); - for (eventCls in BUILTIN_EVENTS) - { - var eventClsName:String = Type.getClassName(eventCls); - if (eventClsName == 'funkin.play.event.SongEvent' || eventClsName == 'funkin.play.event.ScriptedSongEvent') continue; - - var event:SongEvent = Type.createInstance(eventCls, ["UNKNOWN"]); - - if (event != null) - { - trace(' Loaded built-in song event: (${event.id})'); - eventCache.set(event.id, event); - } - else - { - trace(' Failed to load built-in song event: ${Type.getClassName(eventCls)}'); - } - } - } - - static function registerScriptedEvents() - { - var scriptedEventClassNames:Array = ScriptedSongEvent.listScriptClasses(); - if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0) return; - - trace('Instantiating ${scriptedEventClassNames.length} scripted song events...'); - for (eventCls in scriptedEventClassNames) - { - var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN"); - - if (event != null) - { - trace(' Loaded scripted song event: ${event.id}'); - eventCache.set(event.id, event); - } - else - { - trace(' Failed to instantiate scripted song event class: ${eventCls}'); - } - } - } - - public static function listEventIds():Array - { - return eventCache.keys().array(); - } - - public static function listEvents():Array - { - return eventCache.values(); - } - - public static function getEvent(id:String):SongEvent - { - return eventCache.get(id); - } - - public static function getEventSchema(id:String):SongEventSchema - { - var event:SongEvent = getEvent(id); - if (event == null) return null; - - return event.getEventSchema(); - } - - static function clearEventCache() - { - eventCache.clear(); - } - - public static function handleEvent(data:SongEventData):Void - { - var eventType:String = data.event; - var eventHandler:SongEvent = eventCache.get(eventType); - - if (eventHandler != null) - { - eventHandler.handleEvent(data); - } - else - { - trace('WARNING: No event handler for event with id: ${eventType}'); - } - - data.activated = true; - } - - public static inline function handleEvents(events:Array):Void - { - for (event in events) - { - handleEvent(event); - } - } - - /** - * Given a list of song events and the current timestamp, - * return a list of events that should be handled. - */ - public static function queryEvents(events:Array, currentTime:Float):Array - { - return events.filter(function(event:SongEventData):Bool { - // If the event is already activated, don't activate it again. - if (event.activated) return false; - - // If the event is in the future, don't activate it. - if (event.time > currentTime) return false; - - return true; - }); - } - - /** - * Reset activation of all the provided events. - */ - public static function resetEvents(events:Array):Void - { - for (event in events) - { - event.activated = false; - // TODO: Add an onReset() method to SongEvent? - } - } -} - -enum abstract SongEventFieldType(String) from String to String -{ - /** - * The STRING type will display as a text field. - */ - var STRING = "string"; - - /** - * The INTEGER type will display as a text field that only accepts numbers. - */ - var INTEGER = "integer"; - - /** - * The FLOAT type will display as a text field that only accepts numbers. - */ - var FLOAT = "float"; - - /** - * The BOOL type will display as a checkbox. - */ - var BOOL = "bool"; - - /** - * The ENUM type will display as a dropdown. - * Make sure to specify the `keys` field in the schema. - */ - var ENUM = "enum"; -} - -typedef SongEventSchemaField = -{ - /** - * The name of the property as it should be saved in the event data. - */ - name:String, - - /** - * The title of the field to display in the UI. - */ - title:String, - - /** - * The type of the field. - */ - type:SongEventFieldType, - - /** - * Used for ENUM values. - * The key is the display name and the value is the actual value. - */ - ?keys:Map, - /** - * Used for INTEGER and FLOAT values. - * The minimum value that can be entered. - */ - ?min:Float, - /** - * Used for INTEGER and FLOAT values. - * The maximum value that can be entered. - */ - ?max:Float, - /** - * Used for INTEGER and FLOAT values. - * The step value that will be used when incrementing/decrementing the value. - */ - ?step:Float, - /** - * An optional default value for the field. - */ - ?defaultValue:Dynamic, -} - -typedef SongEventSchema = Array; 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 8847636bd..71cb99ee2 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 72d22191b..2601fd961 100644 --- a/source/funkin/play/notes/SustainTrail.hx +++ b/source/funkin/play/notes/SustainTrail.hx @@ -2,7 +2,7 @@ package funkin.play.notes; import funkin.play.notes.notestyle.NoteStyle; import funkin.play.notes.NoteDirection; -import funkin.play.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongNoteData; import flixel.util.FlxDirectionFlags; import flixel.FlxSprite; import flixel.graphics.FlxGraphic; diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx index 97871b657..34c1ce9c3 100644 --- a/source/funkin/play/notes/notestyle/NoteStyle.hx +++ b/source/funkin/play/notes/notestyle/NoteStyle.hx @@ -104,7 +104,8 @@ class NoteStyle implements IRegistryEntry noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary()); - if (noteFrames == null) { + if (noteFrames == null) + { throw 'Could not load note frames for note style: $id'; } @@ -139,13 +140,13 @@ class NoteStyle implements IRegistryEntry function buildNoteAnimations(target:NoteSprite):Void { var leftData:AnimationData = fetchNoteAnimationData(LEFT); - target.animation.addByPrefix('purpleScroll', leftData.prefix); + target.animation.addByPrefix('purpleScroll', leftData.prefix, leftData.frameRate, leftData.looped, leftData.flipX, leftData.flipY); var downData:AnimationData = fetchNoteAnimationData(DOWN); - target.animation.addByPrefix('blueScroll', downData.prefix); + target.animation.addByPrefix('blueScroll', downData.prefix, downData.frameRate, downData.looped, downData.flipX, downData.flipY); var upData:AnimationData = fetchNoteAnimationData(UP); - target.animation.addByPrefix('greenScroll', upData.prefix); + target.animation.addByPrefix('greenScroll', upData.prefix, upData.frameRate, upData.looped, upData.flipX, upData.flipY); var rightData:AnimationData = fetchNoteAnimationData(RIGHT); - target.animation.addByPrefix('redScroll', rightData.prefix); + target.animation.addByPrefix('redScroll', rightData.prefix, rightData.frameRate, rightData.looped, rightData.flipX, rightData.flipY); } function fetchNoteAnimationData(dir:NoteDirection):AnimationData @@ -302,7 +303,7 @@ class NoteStyle implements IRegistryEntry return 'NoteStyle($id)'; } - public function _fetchData(id:String):Null + static function _fetchData(id:String):Null { return NoteStyleRegistry.instance.parseEntryDataWithMigration(id, NoteStyleRegistry.instance.fetchEntryVersion(id)); } diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 715629a51..b008f6a8e 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -5,14 +5,16 @@ import openfl.utils.Assets; import funkin.modding.events.ScriptEvent; import funkin.modding.IScriptedClass; import funkin.audio.VoicesGroup; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongDataParser; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongMetadata; -import funkin.play.song.SongData.SongNoteData; -import funkin.play.song.SongData.SongPlayableChar; -import funkin.play.song.SongData.SongTimeChange; -import funkin.play.song.SongData.SongTimeFormat; +import funkin.data.song.SongRegistry; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongRegistry; +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongPlayableChar; +import funkin.data.song.SongData.SongTimeChange; +import funkin.data.song.SongData.SongTimeFormat; +import funkin.data.IRegistryEntry; /** * This is a data structure managing information about the current song. @@ -23,9 +25,26 @@ import funkin.play.song.SongData.SongTimeFormat; * It also receives script events; scripted classes which extend this class * can be used to perform custom gameplay behaviors only on specific songs. */ -class Song implements IPlayStateScriptedClass +@:nullSafety +class Song implements IPlayStateScriptedClass implements IRegistryEntry { - public final songId:String; + public static final DEFAULT_SONGNAME:String = "Unknown"; + public static final DEFAULT_ARTIST:String = "Unknown"; + public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS; + public static final DEFAULT_DIVISIONS:Null = null; + public static final DEFAULT_LOOPED:Bool = false; + public static final DEFAULT_STAGE:String = "mainStage"; + public static final DEFAULT_SCROLLSPEED:Float = 1.0; + + public final id:String; + + /** + * Song metadata as parsed from the JSON file. + * This is the data for the `default` variation specifically, + * and is needed for the IRegistryEntry interface. + * Will only be null if the song data could not be loaded. + */ + public final _data:Null; final _metadata:Array; @@ -39,33 +58,56 @@ class Song implements IPlayStateScriptedClass var difficultyIds:Array; + public var songName(get, never):String; + + function get_songName():String + { + if (_data != null) return _data?.songName ?? DEFAULT_SONGNAME; + if (_metadata.length > 0) return _metadata[0]?.songName ?? DEFAULT_SONGNAME; + return DEFAULT_SONGNAME; + } + + public var songArtist(get, never):String; + + function get_songArtist():String + { + if (_data != null) return _data?.artist ?? DEFAULT_ARTIST; + if (_metadata.length > 0) return _metadata[0]?.artist ?? DEFAULT_ARTIST; + return DEFAULT_ARTIST; + } + /** * @param id The ID of the song to load. * @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded. */ - public function new(id:String, ignoreErrors:Bool = false) + public function new(id:String) { - this.songId = id; + this.id = id; variations = []; difficultyIds = []; difficulties = new Map(); - try + _data = _fetchData(id); + + _metadata = _data == null ? [] : [_data]; + + for (meta in fetchVariationMetadata(id)) + _metadata.push(meta); + + if (_metadata.length == 0) { - _metadata = SongDataParser.loadSongMetadata(songId); - } - catch (e) - { - _metadata = []; + trace('[WARN] Could not find song data for songId: $id'); + return; } - if (_metadata.length == 0 && !ignoreErrors) - { - throw 'Could not find song data for songId: $songId'; - } - else + variations.clear(); + variations.push('default'); + if (_data != null && _data.playData != null) { + for (vari in _data.playData.songVariations) + variations.push(vari); + populateFromMetadata(); } } @@ -74,7 +116,7 @@ class Song implements IPlayStateScriptedClass public static function buildRaw(songId:String, metadata:Array, variations:Array, charts:Map, validScore:Bool = false):Song { - var result:Song = new Song(songId, true); + var result:Song = new Song(songId); result._metadata.clear(); for (meta in metadata) @@ -112,6 +154,8 @@ class Song implements IPlayStateScriptedClass // Variations may have different artist, time format, generatedBy, etc. for (metadata in _metadata) { + if (metadata == null || metadata.playData == null) continue; + // There may be more difficulties in the chart file than in the metadata, // (i.e. non-playable charts like the one used for Pico on the speaker in Stress) // but all the difficulties in the metadata must be in the chart file. @@ -134,15 +178,16 @@ class Song implements IPlayStateScriptedClass difficulty.stage = metadata.playData.stage; // difficulty.noteSkin = metadata.playData.noteSkin; + difficulties.set(diffId, difficulty); + difficulty.chars = new Map(); + if (metadata.playData.playableChars == null) continue; for (charId in metadata.playData.playableChars.keys()) { - var char = metadata.playData.playableChars.get(charId); - + var char:Null = metadata.playData.playableChars.get(charId); + if (char == null) continue; difficulty.chars.set(charId, char); } - - difficulties.set(diffId, difficulty); } } } @@ -157,11 +202,14 @@ class Song implements IPlayStateScriptedClass clearCharts(); } - trace('Caching ${variations.length} chart files for song $songId'); + trace('Caching ${variations.length} chart files for song $id'); for (variation in variations) { - var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation); - applyChartData(chartData, variation); + var version:Null = SongRegistry.instance.fetchEntryChartVersion(id, variation); + if (version == null) continue; + var chart:Null = SongRegistry.instance.parseEntryChartDataWithMigration(id, version, variation); + if (chart == null) continue; + applyChartData(chart, variation); } trace('Done caching charts.'); } @@ -181,8 +229,8 @@ class Song implements IPlayStateScriptedClass difficulties.set(diffId, difficulty); } // Add the chart data to the difficulty. - difficulty.notes = chartData.notes.get(diffId); - difficulty.scrollSpeed = chartData.getScrollSpeed(diffId); + difficulty.notes = chartNotes.get(diffId) ?? []; + difficulty.scrollSpeed = chartData.getScrollSpeed(diffId) ?? 1.0; difficulty.events = chartData.events; } @@ -193,7 +241,7 @@ class Song implements IPlayStateScriptedClass * @param diffId The difficulty ID, such as `easy` or `hard`. * @return The difficulty data. */ - public inline function getDifficulty(diffId:String = null):SongDifficulty + public inline function getDifficulty(?diffId:String):Null { if (diffId == null) diffId = difficulties.keys().array()[0]; @@ -223,9 +271,11 @@ class Song implements IPlayStateScriptedClass public function toString():String { - return 'Song($songId)'; + return 'Song($id)'; } + public function destroy():Void {} + public function onPause(event:PauseScriptEvent):Void {}; public function onResume(event:ScriptEvent):Void {}; @@ -265,6 +315,27 @@ class Song implements IPlayStateScriptedClass public function onDestroy(event:ScriptEvent):Void {}; public function onUpdate(event:UpdateScriptEvent):Void {}; + + static function _fetchData(id:String):Null + { + trace('Fetching song metadata for $id'); + var version:Null = SongRegistry.instance.fetchEntryMetadataVersion(id); + if (version == null) return null; + return SongRegistry.instance.parseEntryMetadataWithMigration(id, '', version); + } + + function fetchVariationMetadata(id:String):Array + { + var result:Array = []; + for (vari in variations) + { + var version:Null = SongRegistry.instance.fetchEntryMetadataVersion(id, vari); + if (version == null) continue; + var meta:Null = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version); + if (meta != null) result.push(meta); + } + return result; + } } class SongDifficulty @@ -299,7 +370,7 @@ class SongDifficulty public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT; public var divisions:Null = SongValidator.DEFAULT_DIVISIONS; public var looped:Bool = SongValidator.DEFAULT_LOOPED; - public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY; + public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY; public var timeChanges:Array = []; @@ -351,18 +422,18 @@ class SongDifficulty var currentPlayer:Null = getPlayableChar(currentPlayerId); if (currentPlayer != null) { - FlxG.sound.cache(Paths.inst(this.song.songId, currentPlayer.inst)); + FlxG.sound.cache(Paths.inst(this.song.id, currentPlayer.inst)); } else { - FlxG.sound.cache(Paths.inst(this.song.songId)); + FlxG.sound.cache(Paths.inst(this.song.id)); } } public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void { var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; - FlxG.sound.playMusic(Paths.inst(this.song.songId, suffix), volume, looped); + FlxG.sound.playMusic(Paths.inst(this.song.id, suffix), volume, looped); } /** @@ -388,7 +459,7 @@ class SongDifficulty var playableCharData:SongPlayableChar = getPlayableChar(id); if (playableCharData == null) { - trace('Could not find playable char $id for song ${this.song.songId}'); + trace('Could not find playable char $id for song ${this.song.id}'); return []; } @@ -398,24 +469,24 @@ class SongDifficulty // For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`. var playerId:String = id; - var voicePlayer:String = Paths.voices(this.song.songId, '-$id$suffix'); + var voicePlayer:String = Paths.voices(this.song.id, '-$id$suffix'); while (voicePlayer != null && !Assets.exists(voicePlayer)) { // Remove the last suffix. // For example, bf-car becomes bf. playerId = playerId.split('-').slice(0, -1).join('-'); // Try again. - voicePlayer = playerId == '' ? null : Paths.voices(this.song.songId, '-${playerId}$suffix'); + voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); } var opponentId:String = playableCharData.opponent; - var voiceOpponent:String = Paths.voices(this.song.songId, '-${opponentId}$suffix'); + var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix'); while (voiceOpponent != null && !Assets.exists(voiceOpponent)) { // Remove the last suffix. opponentId = opponentId.split('-').slice(0, -1).join('-'); // Try again. - voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.songId, '-${opponentId}$suffix'); + voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); } var result:Array = []; @@ -424,7 +495,7 @@ class SongDifficulty if (voicePlayer == null && voiceOpponent == null) { // Try to use `Voices.ogg` if no other voices are found. - if (Assets.exists(Paths.voices(this.song.songId, ''))) result.push(Paths.voices(this.song.songId, '$suffix')); + if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix')); } return result; } @@ -442,7 +513,7 @@ class SongDifficulty if (voiceList.length == 0) { - trace('Could not find any voices for song ${this.song.songId}'); + trace('Could not find any voices for song ${this.song.id}'); return result; } diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx deleted file mode 100644 index cef4c98f6..000000000 --- a/source/funkin/play/song/SongData.hx +++ /dev/null @@ -1,1021 +0,0 @@ -package funkin.play.song; - -import funkin.modding.events.ScriptEventDispatcher; -import funkin.modding.events.ScriptEvent; -import flixel.util.typeLimit.OneOfTwo; -import funkin.modding.events.ScriptEvent; -import funkin.modding.events.ScriptEventDispatcher; -import funkin.play.song.ScriptedSong; -import funkin.util.assets.DataAssets; -import haxe.DynamicAccess; -import haxe.Json; -import openfl.utils.Assets; -import thx.semver.Version; -import funkin.util.SerializerUtil; - -/** - * Contains utilities for loading and parsing stage data. - */ -class SongDataParser -{ - /** - * A list containing all the songs available to the game. - */ - static final songCache:Map = new Map(); - - static final DEFAULT_SONG_ID:String = 'UNKNOWN'; - static final SONG_DATA_PATH:String = 'songs/'; - static final MUSIC_DATA_PATH:String = 'music/'; - static final SONG_DATA_SUFFIX:String = '-metadata.json'; - - /** - * Parses and preloads the game's song metadata and scripts when the game starts. - * - * If you want to force song metadata to be reloaded, you can just call this function again. - */ - public static function loadSongCache():Void - { - clearSongCache(); - trace('Loading song cache...'); - - // - // SCRIPTED SONGS - // - var scriptedSongClassNames:Array = ScriptedSong.listScriptClasses(); - trace(' Instantiating ${scriptedSongClassNames.length} scripted songs...'); - for (songCls in scriptedSongClassNames) - { - var song:Song = ScriptedSong.init(songCls, DEFAULT_SONG_ID); - if (song != null) - { - trace(' Loaded scripted song: ${song.songId}'); - songCache.set(song.songId, song); - } - else - { - trace(' Failed to instantiate scripted song class: ${songCls}'); - } - } - - // - // UNSCRIPTED SONGS - // - var songIdList:Array = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX).map(function(songDataPath:String):String { - return songDataPath.split('/')[0]; - }); - var unscriptedSongIds:Array = songIdList.filter(function(songId:String):Bool { - return !songCache.exists(songId); - }); - trace(' Instantiating ${unscriptedSongIds.length} non-scripted songs...'); - for (songId in unscriptedSongIds) - { - try - { - var song:Song = new Song(songId); - if (song != null) - { - trace(' Loaded song data: ${song.songId}'); - songCache.set(song.songId, song); - } - } - catch (e) - { - trace(' An error occurred while loading song data: ${songId}'); - trace(e); - // Assume error was already logged. - continue; - } - } - - trace(' Successfully loaded ${Lambda.count(songCache)} stages.'); - } - - /** - * Retrieves a particular song from the cache. - * @param songId The ID of the song to retrieve. - * @return The song, or null if it was not found. - */ - public static function fetchSong(songId:String):Null - { - if (songCache.exists(songId)) - { - var song:Song = songCache.get(songId); - trace('Successfully fetch song: ${songId}'); - - var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false); - ScriptEventDispatcher.callEvent(song, event); - return song; - } - else - { - trace('Failed to fetch song, not found in cache: ${songId}'); - return null; - } - } - - static function clearSongCache():Void - { - if (songCache != null) - { - songCache.clear(); - } - } - - /** - * A list of all the song IDs available to the game. - * @return The list of song IDs. - */ - public static function listSongIds():Array - { - return songCache.keys().array(); - } - - /** - * Loads the song metadata for a particular song. - * @param songId The ID of the song to load. - * @return The song metadata for each variation, or an empty array if the song was not found. - */ - public static function loadSongMetadata(songId:String):Array - { - var result:Array = []; - - var jsonStr:String = loadSongMetadataFile(songId); - var jsonData:Dynamic = SerializerUtil.fromJSON(jsonStr); - - var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId); - songMetadata = SongValidator.validateSongMetadata(songMetadata, songId); - - if (songMetadata == null) - { - return result; - } - - result.push(songMetadata); - - var variations:Array = songMetadata.playData.songVariations; - - for (variation in variations) - { - var variationJsonStr:String = loadSongMetadataFile(songId, variation); - var variationJsonData:Dynamic = SerializerUtil.fromJSON(variationJsonStr); - var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationJsonData, '${songId}:${variation}'); - variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}:${variation}'); - if (variationSongMetadata != null) - { - variationSongMetadata.variation = variation; - result.push(variationSongMetadata); - } - } - - return result; - } - - static function loadSongMetadataFile(songPath:String, variation:String = ''):String - { - var songMetadataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata'); - - var rawJson:String = Assets.getText(songMetadataFilePath).trim(); - - while (!rawJson.endsWith('}') && rawJson.length > 0) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } - - public static function parseMusicMetadata(musicId:String):SongMetadata - { - var rawJson:String = loadMusicMetadataFile(musicId); - var jsonData:Dynamic = null; - try - { - jsonData = Json.parse(rawJson); - } - catch (e) {} - - var musicMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, musicId); - musicMetadata = SongValidator.validateSongMetadata(musicMetadata, musicId); - - return musicMetadata; - } - - static function loadMusicMetadataFile(musicPath:String, variation:String = ''):String - { - var musicMetadataFilePath:String = (variation != '' || variation == "default") ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json'); - - var rawJson:String = Assets.getText(musicMetadataFilePath).trim(); - - while (!rawJson.endsWith("}")) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } - - public static function parseSongChartData(songId:String, variation:String = ''):SongChartData - { - var rawJson:String = loadSongChartDataFile(songId, variation); - var jsonData:Dynamic = null; - try - { - jsonData = Json.parse(rawJson); - } - catch (e) - { - trace('Failed to parse song chart data: ${songId} (${variation})'); - trace(e); - } - - var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId); - songChartData = SongValidator.validateSongChartData(songChartData, songId); - - if (songChartData == null) - { - trace('Failed to validate song chart data: ${songId}'); - return null; - } - - return songChartData; - } - - static function loadSongChartDataFile(songPath:String, variation:String = ''):String - { - var songChartDataFilePath:String = (variation != '' && variation != 'default') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart'); - - var rawJson:String = Assets.getText(songChartDataFilePath).trim(); - - while (!rawJson.endsWith('}') && rawJson.length > 0) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } -} - -typedef RawSongMetadata = -{ - /** - * A semantic versioning string for the song data format. - * - */ - var version:Version; - - var songName:String; - var artist:String; - var timeFormat:SongTimeFormat; - var divisions:Null; // Optional field - var timeChanges:Array; - var looped:Bool; - var playData:SongPlayData; - var generatedBy:String; - - /** - * Defaults to `default` or `''`. Populated later. - */ - var variation:String; -}; - -@:forward -abstract SongMetadata(RawSongMetadata) -{ - public function new(songName:String, artist:String, variation:String = 'default') - { - this = - { - version: SongMigrator.CHART_VERSION, - songName: songName, - artist: artist, - timeFormat: 'ms', - divisions: null, - timeChanges: [new SongTimeChange(-1, 0, 100, 4, 4, [4, 4, 4, 4])], - looped: false, - playData: - { - songVariations: [], - difficulties: ['normal'], - - playableChars: - { - bf: new SongPlayableChar('gf', 'dad'), - }, - - stage: 'mainStage', - noteSkin: 'Normal' - }, - generatedBy: SongValidator.DEFAULT_GENERATEDBY, - - // Variation ID. - variation: variation - }; - } - - public function clone(?newVariation:String = null):SongMetadata - { - var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); - result.version = this.version; - result.timeFormat = this.timeFormat; - result.divisions = this.divisions; - result.timeChanges = this.timeChanges; - result.looped = this.looped; - result.playData = this.playData; - result.generatedBy = this.generatedBy; - - return result; - } -} - -typedef SongPlayData = -{ - var songVariations:Array; - var difficulties:Array; - - /** - * Keys are the player characters and the values give info on what opponent/GF/inst to use. - */ - var playableChars:DynamicAccess; - - var stage:String; - var noteSkin:String; -} - -typedef RawSongPlayableChar = -{ - var g:String; - var o:String; - var i:String; -} - -typedef RawSongNoteData = -{ - /** - * The timestamp of the note. The timestamp is in the format of the song's time format. - */ - var t:Float; - - /** - * Data for the note. Represents the index on the strumline. - * 0 = left, 1 = down, 2 = up, 3 = right - * `floor(direction / strumlineSize)` specifies which strumline the note is on. - * 0 = player, 1 = opponent, etc. - */ - var d:Int; - - /** - * Length of the note, if applicable. - * Defaults to 0 for single notes. - */ - var l:Float; - - /** - * The kind of the note. - * This can allow the note to include information used for custom behavior. - * Defaults to blank or `"normal"`. - */ - var k:String; -} - -abstract SongNoteData(RawSongNoteData) -{ - public function new(time:Float, data:Int, length:Float = 0, kind:String = '') - { - this = - { - t: time, - d: data, - l: length, - k: kind - }; - } - - /** - * The timestamp of the note, in milliseconds. - */ - public var time(get, set):Float; - - function get_time():Float - { - return this.t; - } - - function set_time(value:Float):Float - { - return this.t = value; - } - - /** - * The timestamp of the note, in steps. - */ - public var stepTime(get, never):Float; - - function get_stepTime():Float - { - return Conductor.getTimeInSteps(abstract.time); - } - - /** - * The raw data for the note. - */ - public var data(get, set):Int; - - function get_data():Int - { - return this.d; - } - - function set_data(value:Int):Int - { - return this.d = value; - } - - /** - * The direction of the note, if applicable. - * Strips the strumline index from the data. - * - * 0 = left, 1 = down, 2 = up, 3 = right - */ - public inline function getDirection(strumlineSize:Int = 4):Int - { - return abstract.data % strumlineSize; - } - - public function getDirectionName(strumlineSize:Int = 4):String - { - switch (abstract.data % strumlineSize) - { - case 0: - return 'Left'; - case 1: - return 'Down'; - case 2: - return 'Up'; - case 3: - return 'Right'; - default: - return 'Unknown'; - } - } - - /** - * The strumline index of the note, if applicable. - * Strips the direction from the data. - * - * 0 = player, 1 = opponent, etc. - */ - public inline function getStrumlineIndex(strumlineSize:Int = 4):Int - { - return Math.floor(abstract.data / strumlineSize); - } - - /** - * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side). - * TODO: The name of this function is a little misleading; what about mines? - * @param strumlineSize Defaults to 4. - * @return True if it's Boyfriend's note. - */ - public inline function getMustHitNote(strumlineSize:Int = 4):Bool - { - return getStrumlineIndex(strumlineSize) == 0; - } - - /** - * If this is a hold note, this is the length of the hold note in milliseconds. - * @default 0 (not a hold note) - */ - public var length(get, set):Float; - - function get_length():Float - { - return this.l; - } - - function set_length(value:Float):Float - { - return this.l = value; - } - - /** - * If this is a hold note, this is the length of the hold note in steps. - * @default 0 (not a hold note) - */ - public var stepLength(get, set):Float; - - function get_stepLength():Float - { - return Conductor.getTimeInSteps(abstract.time + abstract.length) - abstract.stepTime; - } - - function set_stepLength(value:Float):Float - { - return abstract.length = Conductor.getStepTimeInMs(value) - abstract.time; - } - - public var isHoldNote(get, never):Bool; - - public function get_isHoldNote():Bool - { - return this.l > 0; - } - - public var kind(get, set):String; - - function get_kind():String - { - if (this.k == null || this.k == '') return 'normal'; - - return this.k; - } - - function set_kind(value:String):String - { - if (value == 'normal' || value == '') value = null; - return this.k = value; - } - - @:op(A == B) - public function op_equals(other:SongNoteData):Bool - { - if (abstract.kind == '') - { - if (other.kind != '' && other.kind != 'normal') return false; - } - else - { - if (other.kind == '' || other.kind != abstract.kind) return false; - } - - return abstract.time == other.time && abstract.data == other.data && abstract.length == other.length; - } - - @:op(A != B) - public function op_notEquals(other:SongNoteData):Bool - { - if (abstract.kind == '') - { - if (other.kind != '' && other.kind != 'normal') return true; - } - else - { - if (other.kind == '' || other.kind != abstract.kind) return true; - } - - return abstract.time != other.time || abstract.data != other.data || abstract.length != other.length; - } - - @:op(A > B) - public function op_greaterThan(other:SongNoteData):Bool - { - return abstract.time > other.time; - } - - @:op(A < B) - public function op_lessThan(other:SongNoteData):Bool - { - return this.t < other.time; - } - - @:op(A >= B) - public function op_greaterThanOrEquals(other:SongNoteData):Bool - { - return this.t >= other.time; - } - - @:op(A <= B) - public function op_lessThanOrEquals(other:SongNoteData):Bool - { - return this.t <= other.time; - } -} - -typedef RawSongEventData = -{ - /** - * The timestamp of the event. The timestamp is in the format of the song's time format. - */ - var t:Float; - - /** - * The kind of the event. - * Examples include "FocusCamera" and "PlayAnimation" - * Custom events can be added by scripts with the `ScriptedSongEvent` class. - */ - var e:String; - - /** - * The data for the event. - * This can allow the event to include information used for custom behavior. - * Data type depends on the event kind. It can be anything that's JSON serializable. - */ - var v:DynamicAccess; - - /** - * Whether this event has been activated. - * This is only used internally by the game. It should not be serialized. - */ - @:optional var a:Bool; -} - -abstract SongEventData(RawSongEventData) -{ - public function new(time:Float, event:String, value:Dynamic = null) - { - this = - { - t: time, - e: event, - v: value, - a: false - }; - } - - public var time(get, set):Float; - - function get_time():Float - { - return this.t; - } - - function set_time(value:Float):Float - { - return this.t = value; - } - - public var stepTime(get, never):Float; - - function get_stepTime():Float - { - return Conductor.getTimeInSteps(abstract.time); - } - - public var event(get, set):String; - - function get_event():String - { - return this.e; - } - - function set_event(value:String):String - { - return this.e = value; - } - - public var value(get, set):Dynamic; - - function get_value():Dynamic - { - return this.v; - } - - function set_value(value:Dynamic):Dynamic - { - return this.v = value; - } - - public var activated(get, set):Bool; - - function get_activated():Bool - { - return this.a; - } - - function set_activated(value:Bool):Bool - { - return this.a = value; - } - - public inline function getDynamic(key:String):Null - { - return this.v.get(key); - } - - public inline function getBool(key:String):Null - { - return cast this.v.get(key); - } - - public inline function getInt(key:String):Null - { - return cast this.v.get(key); - } - - public inline function getFloat(key:String):Null - { - return cast this.v.get(key); - } - - public inline function getString(key:String):String - { - return cast this.v.get(key); - } - - public inline function getArray(key:String):Array - { - return cast this.v.get(key); - } - - public inline function getBoolArray(key:String):Array - { - return cast this.v.get(key); - } - - @:op(A == B) - public function op_equals(other:SongEventData):Bool - { - return this.t == other.time && this.e == other.event && this.v == other.value; - } - - @:op(A != B) - public function op_notEquals(other:SongEventData):Bool - { - return this.t != other.time || this.e != other.event || this.v != other.value; - } - - @:op(A > B) - public function op_greaterThan(other:SongEventData):Bool - { - return this.t > other.time; - } - - @:op(A < B) - public function op_lessThan(other:SongEventData):Bool - { - return this.t < other.time; - } - - @:op(A >= B) - public function op_greaterThanOrEquals(other:SongEventData):Bool - { - return this.t >= other.time; - } - - @:op(A <= B) - public function op_lessThanOrEquals(other:SongEventData):Bool - { - return this.t <= other.time; - } -} - -abstract SongPlayableChar(RawSongPlayableChar) -{ - public function new(girlfriend:String, opponent:String, inst:String = '') - { - this = - { - g: girlfriend, - o: opponent, - i: inst - }; - } - - public var girlfriend(get, set):String; - - function get_girlfriend():String - { - return this.g; - } - - function set_girlfriend(value:String):String - { - return this.g = value; - } - - public var opponent(get, set):String; - - function get_opponent():String - { - return this.o; - } - - function set_opponent(value:String):String - { - return this.o = value; - } - - public var inst(get, set):String; - - function get_inst():String - { - return this.i; - } - - function set_inst(value:String):String - { - return this.i = value; - } -} - -typedef RawSongChartData = -{ - var version:Version; - - var scrollSpeed:DynamicAccess; - var events:Array; - var notes:DynamicAccess>; - var generatedBy:String; -}; - -@:forward -abstract SongChartData(RawSongChartData) -{ - public function new(scrollSpeed:Float, events:Array, notes:Array) - { - this = - { - version: SongMigrator.CHART_VERSION, - - events: events, - notes: - { - normal: notes - }, - scrollSpeed: - { - normal: scrollSpeed - }, - generatedBy: SongValidator.DEFAULT_GENERATEDBY - } - } - - public function getScrollSpeed(diff:String = 'default'):Float - { - var result:Float = this.scrollSpeed.get(diff); - - if (result == 0.0 && diff != 'default') return getScrollSpeed('default'); - - return (result == 0.0) ? 1.0 : result; - } - - public function setScrollSpeed(value:Float, diff:String = 'default'):Float - { - return this.scrollSpeed.set(diff, value); - } - - public function getNotes(diff:String):Array - { - var result:Array = this.notes.get(diff); - - if (result == null && diff != 'normal') return getNotes('normal'); - - return (result == null) ? [] : result; - } - - public function setNotes(value:Array, diff:String):Array - { - return this.notes.set(diff, value); - } - - public function getEvents():Array - { - return this.events; - } - - public function setEvents(value:Array):Array - { - return this.events = value; - } -} - -typedef RawSongTimeChange = -{ - /** - * Timestamp in specified `timeFormat`. - */ - var t:Float; - - /** - * Time in beats (int). The game will calculate further beat values based on this one, - * so it can do it in a simple linear fashion. - */ - var b:Null; - - /** - * Quarter notes per minute (float). Cannot be empty in the first element of the list, - * but otherwise it's optional, and defaults to the value of the previous element. - */ - var bpm:Float; - - /** - * Time signature numerator (int). Optional, defaults to 4. - */ - var n:Int; - - /** - * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. - */ - var d:Int; - - /** - * Beat tuplets (Array or int). This defines how many steps each beat is divided into. - * It can either be an array of length `n` (see above) or a single integer number. - * Optional, defaults to `[4]`. - */ - var bt:OneOfTwo>; -} - -/** - * Add aliases to the minimalized property names of the typedef, - * to improve readability. - */ -abstract SongTimeChange(RawSongTimeChange) from RawSongTimeChange -{ - public function new(timeStamp:Float, ?beatTime:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array) - { - this = - { - t: timeStamp, - b: beatTime, - bpm: bpm, - n: timeSignatureNum, - d: timeSignatureDen, - bt: beatTuplets, - } - } - - public var timeStamp(get, set):Float; - - function get_timeStamp():Float - { - return this.t; - } - - function set_timeStamp(value:Float):Float - { - return this.t = value; - } - - public var beatTime(get, set):Null; - - public function get_beatTime():Null - { - return this.b; - } - - public function set_beatTime(value:Null):Null - { - return this.b = value; - } - - public var bpm(get, set):Float; - - function get_bpm():Float - { - return this.bpm; - } - - function set_bpm(value:Float):Float - { - return this.bpm = value; - } - - public var timeSignatureNum(get, set):Int; - - function get_timeSignatureNum():Int - { - return this.n; - } - - function set_timeSignatureNum(value:Int):Int - { - return this.n = value; - } - - public var timeSignatureDen(get, set):Int; - - function get_timeSignatureDen():Int - { - return this.d; - } - - function set_timeSignatureDen(value:Int):Int - { - return this.d = value; - } - - public var beatTuplets(get, set):Array; - - function get_beatTuplets():Array - { - if (Std.isOfType(this.bt, Int)) - { - return [this.bt]; - } - else - { - return this.bt; - } - } - - function set_beatTuplets(value:Array):Array - { - return this.bt = value; - } -} - -enum abstract SongTimeFormat(String) from String to String -{ - var TICKS = 'ticks'; - var FLOAT = 'float'; - var MILLISECONDS = 'ms'; -} diff --git a/source/funkin/play/song/SongDataUtils.hx b/source/funkin/play/song/SongDataUtils.hx deleted file mode 100644 index a7cbd1b6c..000000000 --- a/source/funkin/play/song/SongDataUtils.hx +++ /dev/null @@ -1,232 +0,0 @@ -package funkin.play.song; - -import flixel.util.FlxSort; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongNoteData; -import funkin.util.ClipboardUtil; -import funkin.util.SerializerUtil; - -using Lambda; - -class SongDataUtils -{ - /** - * Given an array of SongNoteData objects, return a new array of SongNoteData objects - * whose timestamps are shifted by the given amount. - * Does not mutate the original array. - * - * @param notes The notes to modify. - * @param offset The time difference to apply in milliseconds. - */ - public static function offsetSongNoteData(notes:Array, offset:Int):Array - { - return notes.map(function(note:SongNoteData):SongNoteData { - return new SongNoteData(note.time + offset, note.data, note.length, note.kind); - }); - } - - /** - * Given an array of SongEventData objects, return a new array of SongEventData objects - * whose timestamps are shifted by the given amount. - * Does not mutate the original array. - * - * @param events The events to modify. - * @param offset The time difference to apply in milliseconds. - */ - public static function offsetSongEventData(events:Array, offset:Int):Array - { - return events.map(function(event:SongEventData):SongEventData { - return new SongEventData(event.time + offset, event.event, event.value); - }); - } - - /** - * Return a new array without a certain subset of notes from an array of SongNoteData objects. - * Does not mutate the original array. - * - * @param notes The array of notes to be subtracted from. - * @param subtrahend The notes to remove from the `notes` array. Yes, subtrahend is a real word. - */ - public static function subtractNotes(notes:Array, subtrahend:Array) - { - if (notes.length == 0 || subtrahend.length == 0) return notes; - - var result = notes.filter(function(note:SongNoteData):Bool { - for (x in subtrahend) - // SongNoteData's == operation has been overridden so that this will work. - if (x == note) return false; - - return true; - }); - - return result; - } - - /** - * Return a new array without a certain subset of events from an array of SongEventData objects. - * Does not mutate the original array. - * - * @param events The array of events to be subtracted from. - * @param subtrahend The events to remove from the `events` array. Yes, subtrahend is a real word. - */ - public static function subtractEvents(events:Array, subtrahend:Array) - { - if (events.length == 0 || subtrahend.length == 0) return events; - - return events.filter(function(event:SongEventData):Bool { - // SongEventData's == operation has been overridden so that this will work. - return !subtrahend.has(event); - }); - } - - /** - * Create an array of notes whose note data is flipped (player becomes opponent and vice versa) - * Does not mutate the original array. - */ - public static function flipNotes(notes:Array, ?strumlineSize:Int = 4):Array - { - return notes.map(function(note:SongNoteData):SongNoteData { - var newData = note.data; - - if (newData < strumlineSize) newData += strumlineSize; - else - newData -= strumlineSize; - - return new SongNoteData(note.time, newData, note.length, note.kind); - }); - } - - /** - * Prepare an array of notes to be used as the clipboard data. - * - * Offset the provided array of notes such that the first note is at 0 milliseconds. - */ - public static function buildNoteClipboard(notes:Array, ?timeOffset:Int = null):Array - { - if (notes.length == 0) return notes; - if (timeOffset == null) timeOffset = -Std.int(notes[0].time); - return offsetSongNoteData(sortNotes(notes), timeOffset); - } - - /** - * Prepare an array of events to be used as the clipboard data. - * - * Offset the provided array of events such that the first event is at 0 milliseconds. - */ - public static function buildEventClipboard(events:Array, ?timeOffset:Int = null):Array - { - if (events.length == 0) return events; - if (timeOffset == null) timeOffset = -Std.int(events[0].time); - return offsetSongEventData(sortEvents(events), timeOffset); - } - - /** - * Sort an array of notes by strum time. - */ - public static function sortNotes(notes:Array, desc:Bool = false):Array - { - // TODO: Modifies the array in place. Is this okay? - notes.sort(function(a:SongNoteData, b:SongNoteData):Int { - return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time); - }); - return notes; - } - - /** - * Sort an array of events by strum time. - */ - public static function sortEvents(events:Array, desc:Bool = false):Array - { - // TODO: Modifies the array in place. Is this okay? - events.sort(function(a:SongEventData, b:SongEventData):Int { - return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time); - }); - return events; - } - - /** - * Serialize note and event data and write it to the clipboard. - */ - public static function writeItemsToClipboard(data:SongClipboardItems):Void - { - var dataString = SerializerUtil.toJSON(data); - - ClipboardUtil.setClipboard(dataString); - - trace('Wrote ' + data.notes.length + ' notes and ' + data.events.length + ' events to clipboard.'); - - trace(dataString); - } - - /** - * Read an array of note data from the clipboard and deserialize it. - */ - public static function readItemsFromClipboard():SongClipboardItems - { - var notesString = ClipboardUtil.getClipboard(); - - trace('Read ${notesString.length} characters from clipboard.'); - - var data:SongClipboardItems = notesString.parseJSON(); - - if (data == null) - { - trace('Failed to parse notes from clipboard.'); - return { - notes: [], - events: [] - }; - } - else - { - trace('Parsed ' + data.notes.length + ' notes and ' + data.events.length + ' from clipboard.'); - return data; - } - } - - /** - * Filter a list of notes to only include notes that are within the given time range. - */ - public static function getNotesInTimeRange(notes:Array, start:Float, end:Float):Array - { - return notes.filter(function(note:SongNoteData):Bool { - return note.time >= start && note.time <= end; - }); - } - - /** - * Filter a list of events to only include events that are within the given time range. - */ - public static function getEventsInTimeRange(events:Array, start:Float, end:Float):Array - { - return events.filter(function(event:SongEventData):Bool { - return event.time >= start && event.time <= end; - }); - } - - /** - * Filter a list of notes to only include notes whose data is within the given range. - */ - public static function getNotesInDataRange(notes:Array, start:Int, end:Int):Array - { - return notes.filter(function(note:SongNoteData):Bool { - return note.data >= start && note.data <= end; - }); - } - - /** - * Filter a list of notes to only include notes whose data is one of the given values. - */ - public static function getNotesWithData(notes:Array, data:Array):Array - { - return notes.filter(function(note:SongNoteData):Bool { - return data.indexOf(note.data) != -1; - }); - } -} - -typedef SongClipboardItems = -{ - notes:Array, - events:Array -} diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx index bb8718bb7..48ae50037 100644 --- a/source/funkin/play/song/SongMigrator.hx +++ b/source/funkin/play/song/SongMigrator.hx @@ -1,11 +1,11 @@ package funkin.play.song; import funkin.play.song.formats.FNFLegacy; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongEventData; -import funkin.play.song.SongData.SongMetadata; -import funkin.play.song.SongData.SongNoteData; -import funkin.play.song.SongData.SongPlayableChar; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongPlayableChar; import funkin.util.VersionUtil; class SongMigrator @@ -176,7 +176,7 @@ class SongMigrator songMetadata.playData.songVariations = []; // Set the song's song variations. - songMetadata.playData.playableChars = {}; + songMetadata.playData.playableChars = []; try { Reflect.setField(songMetadata.playData.playableChars, songData.song.player1, new SongPlayableChar('', songData.song.player2)); @@ -203,7 +203,7 @@ class SongMigrator var songData:FNFLegacy = cast jsonData; - var songChartData:SongChartData = new SongChartData(1.0, [], []); + var songChartData:SongChartData = new SongChartData(["normal" => 1.0], [], ["normal" => []]); var songEventsEmpty:Bool = songChartData.getEvents() == null || songChartData.getEvents().length == 0; if (songEventsEmpty) songChartData.setEvents(migrateSongEventDataFromLegacy(songData.song.notes)); diff --git a/source/funkin/play/song/SongSerializer.hx b/source/funkin/play/song/SongSerializer.hx index a08b722da..a0a468c5b 100644 --- a/source/funkin/play/song/SongSerializer.hx +++ b/source/funkin/play/song/SongSerializer.hx @@ -1,7 +1,7 @@ package funkin.play.song; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; import funkin.util.SerializerUtil; import lime.utils.Bytes; import openfl.events.Event; diff --git a/source/funkin/play/song/SongValidator.hx b/source/funkin/play/song/SongValidator.hx index 16ea88664..3dacbf2c7 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, @@ -20,13 +21,6 @@ class SongValidator public static final DEFAULT_STAGE:String = "mainStage"; public static final DEFAULT_SCROLLSPEED:Float = 1.0; - public static var DEFAULT_GENERATEDBY(get, null):String; - - static function get_DEFAULT_GENERATEDBY():String - { - return '${Constants.TITLE} - ${Constants.VERSION}'; - } - /** * Validates the fields of a SongMetadata object (excluding the version field). * @@ -59,7 +53,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 fd179c481..64ad3a3ff 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 e5b2d332c..88cf31332 100644 --- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx @@ -3,8 +3,8 @@ package funkin.ui.debug.charting; import funkin.play.character.CharacterData; import funkin.util.Constants; import funkin.util.SerializerUtil; -import funkin.play.song.SongData.SongChartData; -import funkin.play.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; import flixel.util.FlxTimer; import funkin.util.SortUtil; import funkin.input.Cursor; @@ -13,9 +13,9 @@ import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.song.Song; import funkin.play.song.SongMigrator; import funkin.play.song.SongValidator; -import funkin.play.song.SongData.SongDataParser; -import funkin.play.song.SongData.SongPlayableChar; -import funkin.play.song.SongData.SongTimeChange; +import funkin.data.song.SongRegistry; +import funkin.data.song.SongData.SongPlayableChar; +import funkin.data.song.SongData.SongTimeChange; import funkin.util.FileUtil; import haxe.io.Path; import haxe.ui.components.Button; @@ -106,18 +106,17 @@ class ChartEditorDialogHandler var splashTemplateContainer:VBox = dialog.findComponent('splashTemplateContainer', VBox); - var songList:Array = SongDataParser.listSongIds(); + var songList:Array = SongRegistry.instance.listEntryIds(); songList.sort(SortUtil.alphabetically); for (targetSongId in songList) { - var songData:Song = SongDataParser.fetchSong(targetSongId); - + var songData:Null = SongRegistry.instance.fetchEntry(targetSongId); if (songData == null) continue; - var songName:Null = songData.getDifficulty('normal') ?.songName; - if (songName == null) songName = songData.getDifficulty() ?.songName; - if (songName == null) + var songName:Null = songData.getDifficulty('normal')?.songName; + if (songName == null) songName = songData.getDifficulty()?.songName; + if (songName == null) // Still null? { trace('[WARN] Could not fetch song name for ${targetSongId}'); } @@ -470,9 +469,9 @@ class ChartEditorDialogHandler var dialogNoteSkin:DropDown = dialog.findComponent('dialogNoteSkin', DropDown); 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 = null; + state.currentSongNoteSkin = 'funkin'; var dialogBPM:NumberStepper = dialog.findComponent('dialogBPM', NumberStepper); dialogBPM.onChange = function(event:UIEvent) { @@ -481,7 +480,7 @@ class ChartEditorDialogHandler var timeChanges:Array = state.currentSongMetadata.timeChanges; if (timeChanges == null || timeChanges.length == 0) { - timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])]; + timeChanges = [new SongTimeChange(0, event.value)]; } else { @@ -502,7 +501,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', null)); @@ -516,7 +515,8 @@ class ChartEditorDialogHandler { var groupKey:String = key; - var getCharData:Void->SongPlayableChar = function() { + var getCharData:Void->Null = function():Null { + if (state.currentSongMetadata.playData == null) return null; if (groupKey == null) groupKey = 'newChar${state.currentSongMetadata.playData.playableChars.keys().count()}'; var result = state.currentSongMetadata.playData.playableChars.get(groupKey); @@ -528,42 +528,53 @@ class ChartEditorDialogHandler return result; } - var moveCharGroup:String->Void = function(target:String) { - var charData = getCharData(); + var moveCharGroup:String->Void = function(target:String):Void { + var charData:Null = getCharData(); + if (charData == null) return; + + if (state.currentSongMetadata.playData.playableChars == null) return; state.currentSongMetadata.playData.playableChars.remove(groupKey); state.currentSongMetadata.playData.playableChars.set(target, charData); groupKey = target; } - var removeGroup:Void->Void = function() { + var removeGroup:Void->Void = function():Void { + if (state?.currentSongMetadata?.playData?.playableChars == null) return; state.currentSongMetadata.playData.playableChars.remove(groupKey); removeFunc(); } - var charData:SongPlayableChar = getCharData(); + var charData:Null = getCharData(); var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT); - var charGroupPlayer:DropDown = charGroup.findComponent('charGroupPlayer', DropDown); - charGroupPlayer.onChange = function(event:UIEvent) { + var charGroupPlayer:Null = charGroup.findComponent('charGroupPlayer', DropDown); + if (charGroupPlayer == null) throw 'Could not locate charGroupPlayer DropDown in Song Metadata dialog'; + charGroupPlayer.onChange = function(event:UIEvent):Void { + if (charData != null) return; charGroup.text = event.data.text; moveCharGroup(event.data.id); }; - var charGroupOpponent:DropDown = charGroup.findComponent('charGroupOpponent', DropDown); - charGroupOpponent.onChange = function(event:UIEvent) { + var charGroupOpponent:Null = charGroup.findComponent('charGroupOpponent', DropDown); + if (charGroupOpponent == null) throw 'Could not locate charGroupOpponent DropDown in Song Metadata dialog'; + charGroupOpponent.onChange = function(event:UIEvent):Void { + if (charData == null) return; charData.opponent = event.data.id; }; - charGroupOpponent.value = getCharData().opponent; + charGroupOpponent.value = charData.opponent; - var charGroupGirlfriend:DropDown = charGroup.findComponent('charGroupGirlfriend', DropDown); - charGroupGirlfriend.onChange = function(event:UIEvent) { + var charGroupGirlfriend:Null = charGroup.findComponent('charGroupGirlfriend', DropDown); + if (charGroupGirlfriend == null) throw 'Could not locate charGroupGirlfriend DropDown in Song Metadata dialog'; + charGroupGirlfriend.onChange = function(event:UIEvent):Void { + if (charData == null) return; charData.girlfriend = event.data.id; }; - charGroupGirlfriend.value = getCharData().girlfriend; + charGroupGirlfriend.value = charData.girlfriend; - var charGroupRemove:Button = charGroup.findComponent('charGroupRemove', Button); - charGroupRemove.onClick = function(event:UIEvent) { + var charGroupRemove:Null