mirror of
https://github.com/FunkinCrew/Funkin.git
synced 2025-03-24 05:39:50 -04:00
New song data parser
This commit is contained in:
parent
12b5f3fbc1
commit
7e1c11bb25
12 changed files with 1618 additions and 21 deletions
|
@ -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
|
||||
};
|
||||
|
|
99
source/funkin/data/DataParse.hx
Normal file
99
source/funkin/data/DataParse.hx
Normal file
|
@ -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)];
|
||||
}
|
||||
}
|
8
source/funkin/data/DataWrite.hx
Normal file
8
source/funkin/data/DataWrite.hx
Normal file
|
@ -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 {}
|
|
@ -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>;
|
||||
}
|
||||
|
|
21
source/funkin/data/README.md
Normal file
21
source/funkin/data/README.md
Normal file
|
@ -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.
|
||||
|
236
source/funkin/data/event/SongEventData.hx
Normal file
236
source/funkin/data/event/SongEventData.hx
Normal file
|
@ -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>;
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
649
source/funkin/data/song/SongData.hx
Normal file
649
source/funkin/data/song/SongData.hx
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
232
source/funkin/data/song/SongDataUtils.hx
Normal file
232
source/funkin/data/song/SongDataUtils.hx
Normal file
|
@ -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>
|
||||
}
|
262
source/funkin/data/song/SongRegistry.hx
Normal file
262
source/funkin/data/song/SongRegistry.hx
Normal file
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue