From 7e1c11bb25b0c9c5329351e4f262607076967b5d Mon Sep 17 00:00:00 2001 From: EliteMasterEric <ericmyllyoja@gmail.com> Date: Fri, 8 Sep 2023 17:45:47 -0400 Subject: [PATCH] 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<T:(IRegistryEntry<J> & Constructible<EntryConstructo public function fetchEntryVersion(id:String):Null<thx.semver.Version> { - var entryStr:String = loadEntryFile(id); + var entryStr:String = loadEntryFile(id).contents; var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } @@ -145,11 +148,14 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo trace('[' + registryId + '] ' + message); } - function loadEntryFile(id:String):String + function loadEntryFile(id:String):JsonFile { var entryFilePath:String = Paths.json('${dataFilePath}/${id}'); var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); - return rawJson; + return { + fileName: entryFilePath, + contents: rawJson + }; } function clearEntries():Void @@ -188,7 +194,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo } else { - throw '[${registryId}] Entry ${id} does not support migration.'; + throw '[${registryId}] Entry ${id} does not support migration to version ${versionRule}.'; } // Example: @@ -219,4 +225,85 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo * @param clsName */ abstract function createScriptedEntry(clsName:String):Null<T>; + + function printErrors(errors:Array<Error>, id:String = ''):Void + { + trace('[${registryId}] Failed to parse entry data: ${id}'); + + for (error in errors) + printError(error); + } + + function printError(error:Error):Void + { + switch (error) + { + case IncorrectType(vari, expected, pos): + trace(' Expected field "$vari" to be of type "$expected".'); + printPos(pos); + case IncorrectEnumValue(value, expected, pos): + trace(' Invalid enum value (expected "$expected", got "$value")'); + printPos(pos); + case InvalidEnumConstructor(value, expected, pos): + trace(' Invalid enum constructor (epxected "$expected", got "$value")'); + printPos(pos); + case UninitializedVariable(vari, pos): + trace(' Uninitialized variable "$vari"'); + printPos(pos); + case UnknownVariable(vari, pos): + trace(' Unknown variable "$vari"'); + printPos(pos); + case ParserError(message, pos): + trace(' Parsing error: ${message}'); + printPos(pos); + case CustomFunctionException(e, pos): + if (Std.isOfType(e, String)) + { + trace(' ${e}'); + } + else + { + printUnknownError(e); + } + printPos(pos); + default: + printUnknownError(error); + } + } + + function printUnknownError(e:Dynamic):Void + { + switch (Type.typeof(e)) + { + case TClass(c): + trace(' [${Type.getClassName(c)}] ${e.toString()}'); + case TEnum(c): + trace(' [${Type.getEnumName(c)}] ${e.toString()}'); + default: + trace(' [${Type.typeof(e)}] ${e.toString()}'); + } + } + + /** + * TODO: Figure out the nicest way to print this. + * Maybe look up how other JSON parsers format their errors? + * @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx + */ + function printPos(pos:Position):Void + { + if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number) + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}'); + } + else + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}'); + } + } } + +typedef JsonFile = +{ + fileName:String, + contents:String +}; diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx new file mode 100644 index 000000000..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<Dynamic> + { + 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<JObjectField>):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<Dynamic> + */ + static function jsonArrayToDynamicArray(jsons:Array<Json>):Array<Null<Dynamic>> + { + return [for (json in jsons) jsonToDynamic(json)]; + } +} diff --git a/source/funkin/data/DataWrite.hx b/source/funkin/data/DataWrite.hx new file mode 100644 index 000000000..2ff7672da --- /dev/null +++ b/source/funkin/data/DataWrite.hx @@ -0,0 +1,8 @@ +package funkin.data; + +/** + * `json2object` has an annotation `@:jcustomwrite` which allows for custom serialization of values to be written to JSON. + * + * Functions must be of the signature `(T) -> String`, where `T` is the type of the property. + */ +class DataWrite {} diff --git a/source/funkin/data/IRegistryEntry.hx b/source/funkin/data/IRegistryEntry.hx index 0fb704b7c..ff506767d 100644 --- a/source/funkin/data/IRegistryEntry.hx +++ b/source/funkin/data/IRegistryEntry.hx @@ -15,5 +15,6 @@ interface IRegistryEntry<T> // Can't make an interface field private I guess. public final _data:T; - public function _fetchData(id:String):Null<T>; + // Can't make a static field required by an interface I guess. + // private static function _fetchData(id:String):Null<T>; } diff --git a/source/funkin/data/README.md b/source/funkin/data/README.md new file mode 100644 index 000000000..58fa6fa59 --- /dev/null +++ b/source/funkin/data/README.md @@ -0,0 +1,21 @@ +# funkin.data + +Data structures are parsed using `json2object`, which uses macros to generate parser classes based on anonymous structures OR classes. + +Parsing errors will be returned in `parser.errors`. See `json2object.Error` for an enumeration of possible parsing errors. If an error occurred, `parser.value` will be null. + +The properties of these anonymous structures can have their behavior changed with annotations: + +- `@:optional`: The value is optional and will not throw a parsing error if it is not present in the JSON data. +- `@:default("test")`: If the value is optional, this value will be used instead of `null`. Replace `"test"` with a value of the property's type. +- `@:default(auto)`: If the value is an anonymous structure with `json2object` annotations, each field will be initialized to its default value. +- `@:jignored`: This value will be ignored by the parser. Their presence will not be checked in the JSON data and their values will not be parsed. +- `@:alias`: Choose the name the value will use in the JSON data to be separate from the property name. Useful if the desired name is a reserved word like `public`. +- `@:jcustomparse`: Provide a custom function for parsing from a JSON string into a value. + - Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property. + - `hxjsonast.Json` contains a `pos` and a `value`, with `value` being an enum: https://nadako.github.io/hxjsonast/hxjsonast/JsonValue.html + - Errors thrown in this function will cause a parsing error (`CustomFunctionException`) along with a position! + - Make sure to provide the FULLY QUALIFIED path to the custom function. +- `@:jcustomwrite`: Provide a custom function for serializing the property into a string for storage as JSON. + - Functions must be of the signature `(T) -> String`, where `T` is the type of the property. + diff --git a/source/funkin/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<Class<SongEvent>> = 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<String, SongEvent> = new Map<String, SongEvent>(); + + 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<String> = 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<String> + { + return eventCache.keys().array(); + } + + public static function listEvents():Array<SongEvent> + { + 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<SongEventData>):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<SongEventData>, currentTime:Float):Array<SongEventData> + { + 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<SongEventData>):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<String, Dynamic>, + /** + * 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<SongEventSchemaField>; diff --git a/source/funkin/data/level/LevelData.hx b/source/funkin/data/level/LevelData.hx index 0ba26354a..843389cae 100644 --- a/source/funkin/data/level/LevelData.hx +++ b/source/funkin/data/level/LevelData.hx @@ -24,6 +24,7 @@ typedef LevelData = /** * The graphic for the level, as seen in the scrolling list. */ + @:jcustomparse(funkin.data.DataParse.stringNotEmpty) var titleAsset:String; @:default([]) @@ -40,6 +41,7 @@ typedef LevelPropData = /** * The image to use for the prop. May optionally be a sprite sheet. */ + // @:jcustomparse(funkin.data.DataParse.stringNotEmpty) var assetPath:String; /** diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx index 36ce883ea..d135e1241 100644 --- a/source/funkin/data/level/LevelRegistry.hx +++ b/source/funkin/data/level/LevelRegistry.hx @@ -30,17 +30,18 @@ class LevelRegistry extends BaseRegistry<Level, LevelData> // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<LevelData>(); - var jsonStr:String = loadEntryFile(id); - parser.fromJson(jsonStr); + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { - trace('[${registryId}] Failed to parse entry data: ${id}'); - for (error in parser.errors) - { - trace(error); - } + printErrors(parser.errors, id); return null; } return parser.value; diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx index 65f6f627a..bb594bca4 100644 --- a/source/funkin/data/notestyle/NoteStyleRegistry.hx +++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx @@ -34,22 +34,21 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData> */ public function parseEntryData(id:String):Null<NoteStyleData> { - if (id == null) id = DEFAULT_NOTE_STYLE_ID; - // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<NoteStyleData>(); - var jsonStr:String = loadEntryFile(id); - parser.fromJson(jsonStr); + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } if (parser.errors.length > 0) { - trace('[${registryId}] Failed to parse entry data: ${id}'); - for (error in parser.errors) - { - trace(error); - } + printErrors(parser.errors, id); return null; } return parser.value; diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx new file mode 100644 index 000000000..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<Int>; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + /** + * Data relating to the song's gameplay. + */ + public var playData:SongPlayData; + + // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS) + public var timeFormat:SongTimeFormat; + + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES) + public var timeChanges:Array<SongTimeChange>; + + /** + * Defaults to `default` or `''`. Populated later. + */ + @:jignored + public var variation:String = 'default'; + + public function new(songName:String, artist:String, variation:String = 'default') + { + this.version = SongMigrator.CHART_VERSION; + this.songName = songName; + this.artist = artist; + this.timeFormat = 'ms'; + this.divisions = null; + this.timeChanges = [new SongTimeChange(0, 100)]; + this.looped = false; + this.playData = + { + songVariations: [], + difficulties: ['normal'], + + playableChars: ['bf' => new SongPlayableChar('gf', 'dad')], + + stage: 'mainStage', + noteSkin: 'Normal' + }; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = variation; + } + + public function clone(?newVariation:String = null):SongMetadata + { + var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.timeChanges = this.timeChanges; + result.looped = this.looped; + result.playData = this.playData; + result.generatedBy = this.generatedBy; + + return result; + } +} + +enum abstract SongTimeFormat(String) from String to String +{ + var TICKS = 'ticks'; + var FLOAT = 'float'; + var MILLISECONDS = 'ms'; +} + +class SongTimeChange +{ + public static final DEFAULT_SONGTIMECHANGE:SongTimeChange = new SongTimeChange(0, 100); + + public static final DEFAULT_SONGTIMECHANGES:Array<SongTimeChange> = [DEFAULT_SONGTIMECHANGE]; + + static final DEFAULT_BEAT_TUPLETS:Array<Int> = [4, 4, 4, 4]; + static final DEFAULT_BEAT_TIME:Null<Float> = null; // Later, null gets detected and recalculated. + + /** + * Timestamp in specified `timeFormat`. + */ + @:alias("t") + public var timeStamp:Float; + + /** + * Time in beats (int). The game will calculate further beat values based on this one, + * so it can do it in a simple linear fashion. + */ + @:optional + @:alias("b") + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_BEAT_TIME) + public var beatTime:Null<Float>; + + /** + * Quarter notes per minute (float). Cannot be empty in the first element of the list, + * but otherwise it's optional, and defaults to the value of the previous element. + */ + @:alias("bpm") + public var bpm:Float; + + /** + * Time signature numerator (int). Optional, defaults to 4. + */ + @:default(4) + @:optional + @:alias("n") + public var timeSignatureNum:Int; + + /** + * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. + */ + @:default(4) + @:optional + @:alias("d") + public var timeSignatureDen:Int; + + /** + * Beat tuplets (Array<int> or int). This defines how many steps each beat is divided into. + * It can either be an array of length `n` (see above) or a single integer number. + * Optional, defaults to `[4]`. + */ + @:optional + @:alias("bt") + public var beatTuplets:Array<Int>; + + public function new(timeStamp:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, ?beatTime:Float, ?beatTuplets:Array<Int>) + { + this.timeStamp = timeStamp; + this.bpm = bpm; + + this.timeSignatureNum = timeSignatureNum; + this.timeSignatureDen = timeSignatureDen; + + this.beatTime = beatTime == null ? DEFAULT_BEAT_TIME : beatTime; + this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets; + } +} + +/** + * Metadata for a song only used for the music. + * For example, the menu music. + */ +class SongMusicData +{ + /** + * A semantic versioning string for the song data format. + * + */ + // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION) + public var version:Version; + + @:default("Unknown") + public var songName:String; + + @:default("Unknown") + public var artist:String; + + @:optional + @:default(96) + public var divisions:Null<Int>; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS) + public var timeFormat:SongTimeFormat; + + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES) + public var timeChanges:Array<SongTimeChange>; + + /** + * Defaults to `default` or `''`. Populated later. + */ + @:jignored + public var variation:String = 'default'; + + public function new(songName:String, artist:String, variation:String = 'default') + { + this.version = SongMigrator.CHART_VERSION; + this.songName = songName; + this.artist = artist; + this.timeFormat = 'ms'; + this.divisions = null; + this.timeChanges = [new SongTimeChange(0, 100)]; + this.looped = false; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = variation; + } + + public function clone(?newVariation:String = null):SongMusicData + { + var result:SongMusicData = new SongMusicData(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.timeChanges = this.timeChanges; + result.looped = this.looped; + result.generatedBy = this.generatedBy; + + return result; + } +} + +typedef SongPlayData = +{ + public var songVariations:Array<String>; + public var difficulties:Array<String>; + + /** + * Keys are the player characters and the values give info on what opponent/GF/inst to use. + */ + public var playableChars:Map<String, SongPlayableChar>; + + public var stage:String; + public var noteSkin:String; +} + +class SongPlayableChar +{ + @:alias('g') + @:optional + @:default('') + public var girlfriend:String = ''; + + @:alias('o') + @:optional + @:default('') + public var opponent:String = ''; + + @:alias('i') + @:optional + @:default('') + public var inst:String = ''; + + public function new(girlfriend:String = '', opponent:String = '', inst:String = '') + { + this.girlfriend = girlfriend; + this.opponent = opponent; + this.inst = inst; + } +} + +class SongChartData +{ + @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION) + public var version:Version; + + public var scrollSpeed:Map<String, Float>; + public var events:Array<SongEventData>; + public var notes:Map<String, Array<SongNoteData>>; + + @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + public function new(scrollSpeed:Map<String, Float>, events:Array<SongEventData>, notes:Map<String, Array<SongNoteData>>) + { + this.version = SongRegistry.SONG_CHART_DATA_VERSION; + + this.events = events; + this.notes = notes; + this.scrollSpeed = scrollSpeed; + + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + } + + public function getScrollSpeed(diff:String = 'default'):Float + { + var result:Float = this.scrollSpeed.get(diff); + + if (result == 0.0 && diff != 'default') return getScrollSpeed('default'); + + return (result == 0.0) ? 1.0 : result; + } + + public function setScrollSpeed(value:Float, diff:String = 'default'):Float + { + this.scrollSpeed.set(diff, value); + return value; + } + + public function getNotes(diff:String):Array<SongNoteData> + { + var result:Array<SongNoteData> = this.notes.get(diff); + + if (result == null && diff != 'normal') return getNotes('normal'); + + return (result == null) ? [] : result; + } + + public function setNotes(value:Array<SongNoteData>, diff:String):Array<SongNoteData> + { + this.notes.set(diff, value); + return value; + } + + public function getEvents():Array<SongEventData> + { + return this.events; + } + + public function setEvents(value:Array<SongEventData>):Array<SongEventData> + { + return this.events = value; + } +} + +class SongEventData +{ + /** + * The timestamp of the event. The timestamp is in the format of the song's time format. + */ + @:alias("t") + public var time:Float; + + /** + * The kind of the event. + * Examples include "FocusCamera" and "PlayAnimation" + * Custom events can be added by scripts with the `ScriptedSongEvent` class. + */ + @:alias("e") + public var event:String; + + /** + * The data for the event. + * This can allow the event to include information used for custom behavior. + * Data type depends on the event kind. It can be anything that's JSON serializable. + */ + @:alias("v") + @:optional + @:jcustomparse(funkin.data.DataParse.dynamicValue) + public var value:Dynamic = null; + + /** + * Whether this event has been activated. + * This is only used internally by the game. It should not be serialized. + */ + @:jignored + public var activated:Bool = false; + + public function new(time:Float, event:String, value:Dynamic = null) + { + this.time = time; + this.event = event; + this.value = value; + } + + @:jignored + public var stepTime(get, never):Float; + + function get_stepTime():Float + { + return Conductor.getTimeInSteps(this.time); + } + + public inline function getDynamic(key:String):Null<Dynamic> + { + return value == null ? null : Reflect.field(value, key); + } + + public inline function getBool(key:String):Null<Bool> + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getInt(key:String):Null<Int> + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getFloat(key:String):Null<Float> + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getString(key:String):String + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getArray(key:String):Array<Dynamic> + { + return value == null ? null : cast Reflect.field(value, key); + } + + public inline function getBoolArray(key:String):Array<Bool> + { + return value == null ? null : cast Reflect.field(value, key); + } + + @:op(A == B) + public function op_equals(other:SongEventData):Bool + { + return this.time == other.time && this.event == other.event && this.value == other.value; + } + + @:op(A != B) + public function op_notEquals(other:SongEventData):Bool + { + return this.time != other.time || this.event != other.event || this.value != other.value; + } + + @:op(A > B) + public function op_greaterThan(other:SongEventData):Bool + { + return this.time > other.time; + } + + @:op(A < B) + public function op_lessThan(other:SongEventData):Bool + { + return this.time < other.time; + } + + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongEventData):Bool + { + return this.time >= other.time; + } + + @:op(A <= B) + public function op_lessThanOrEquals(other:SongEventData):Bool + { + return this.time <= other.time; + } +} + +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<SongNoteData>, offset:Int):Array<SongNoteData> + { + 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<SongEventData>, offset:Int):Array<SongEventData> + { + 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<SongNoteData>, subtrahend:Array<SongNoteData>) + { + 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<SongEventData>, subtrahend:Array<SongEventData>) + { + 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<SongNoteData>, ?strumlineSize:Int = 4):Array<SongNoteData> + { + 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<SongNoteData>, ?timeOffset:Int = null):Array<SongNoteData> + { + 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<SongEventData>, ?timeOffset:Int = null):Array<SongEventData> + { + 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<SongNoteData>, desc:Bool = false):Array<SongNoteData> + { + // 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<SongEventData>, desc:Bool = false):Array<SongEventData> + { + // 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<SongNoteData>, start:Float, end:Float):Array<SongNoteData> + { + 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<SongEventData>, start:Float, end:Float):Array<SongEventData> + { + 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<SongNoteData>, start:Int, end:Int):Array<SongNoteData> + { + 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<SongNoteData>, data:Array<Int>):Array<SongNoteData> + { + return notes.filter(function(note:SongNoteData):Bool { + return data.indexOf(note.data) != -1; + }); + } +} + +typedef SongClipboardItems = +{ + notes:Array<SongNoteData>, + events:Array<SongEventData> +} 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<Song, SongMetadata> +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final SONG_METADATA_VERSION:thx.semver.Version = "2.0.0"; + + public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + + public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0"; + + public static final SONG_CHART_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + + public static var DEFAULT_GENERATEDBY(get, null):String; + + static function get_DEFAULT_GENERATEDBY():String + { + return '${Constants.TITLE} - ${Constants.VERSION}'; + } + + public static final instance:SongRegistry = new SongRegistry(); + + public function new() + { + super('SONG', 'songs', SONG_METADATA_VERSION_RULE); + } + + public override function loadEntries():Void + { + clearEntries(); + + // + // SCRIPTED ENTRIES + // + var scriptedEntryClassNames:Array<String> = getScriptedClassNames(); + log('Registering ${scriptedEntryClassNames.length} scripted entries...'); + + for (entryCls in scriptedEntryClassNames) + { + var entry:Song = createScriptedEntry(entryCls); + + if (entry != null) + { + log('Successfully created scripted entry (${entryCls} = ${entry.id})'); + entries.set(entry.id, entry); + } + else + { + log('Failed to create scripted entry (${entryCls})'); + } + } + + // + // UNSCRIPTED ENTRIES + // + var entryIdList:Array<String> = DataAssets.listDataFilesInPath('songs/', '-metadata.json').map(function(songDataPath:String):String { + return songDataPath.split('/')[0]; + }); + var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool { + return !entries.exists(entryId); + }); + log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...'); + for (entryId in unscriptedEntryIds) + { + try + { + var entry:Song = createEntry(entryId); + if (entry != null) + { + trace(' Loaded entry data: ${entry}'); + entries.set(entry.id, entry); + } + } + catch (e:Dynamic) + { + // Print the error. + trace(' Failed to load entry data: ${entryId}'); + trace(e); + continue; + } + } + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null<SongMetadata> + { + return parseEntryMetadata(id); + } + + public function parseEntryMetadata(id:String, variation:String = ""):Null<SongMetadata> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + + var parser = new json2object.JsonParser<SongMetadata>(); + switch (loadEntryMetadataFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryMetadataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null<SongMetadata> + { + // If a version rule is not specified, do not check against it. + if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE)) + { + return parseEntryMetadata(id); + } + else + { + throw '[${registryId}] Metadata entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.'; + } + } + + public function parseMusicData(id:String, variation:String = ""):Null<SongMusicData> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + + var parser = new json2object.JsonParser<SongMusicData>(); + switch (loadMusicDataFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryChartData(id:String, variation:String = ''):Null<SongChartData> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser<SongChartData>(); + + switch (loadEntryChartFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryChartDataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null<SongChartData> + { + // If a version rule is not specified, do not check against it. + if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE)) + { + return parseEntryChartData(id, variation); + } + else + { + throw '[${registryId}] Chart entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + } + } + + function createScriptedEntry(clsName:String):Song + { + return ScriptedSong.init(clsName, "unknown"); + } + + function getScriptedClassNames():Array<String> + { + return ScriptedSong.listScriptClasses(); + } + + function loadEntryMetadataFile(id:String, variation:String = ''):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<thx.semver.Version> + { + 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<thx.semver.Version> + { + 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<String> + { + return [ + "tutorial", "bopeebo", "fresh", "dadbattle", "spookeez", "south", "monster", "pico", "philly-nice", "blammed", "satin-panties", "high", "milf", "cocoa", + "eggnog", "winter-horrorland", "senpai", "roses", "thorns", "ugh", "guns", "stress", "darnell", "lit-up", "2hot", "blazin" + ]; + } + + /** + * A list of all installed story weeks that are not from the base game. + */ + public function listModdedSongIds():Array<String> + { + return listEntryIds().filter(function(id:String):Bool { + return listBaseGameSongIds().indexOf(id) == -1; + }); + } +}