New song data parser

This commit is contained in:
EliteMasterEric 2023-09-08 17:45:47 -04:00
parent 12b5f3fbc1
commit 7e1c11bb25
12 changed files with 1618 additions and 21 deletions

View file

@ -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
};

View 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)];
}
}

View 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 {}

View file

@ -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>;
}

View 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.

View 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>;

View file

@ -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;
/**

View file

@ -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;

View file

@ -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;

View 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;
}
}

View 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>
}

View 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;
});
}
}