1
0
Fork 0
mirror of https://github.com/ninjamuffin99/Funkin.git synced 2024-12-25 22:47:52 +00:00

Merge remote-tracking branch 'origin/bugfix-squash/song-data-parser-rework' into HEAD

This commit is contained in:
EliteMasterEric 2023-09-08 18:23:55 -04:00
commit ef1cb10bb3
67 changed files with 5374 additions and 1349 deletions

6
docs/troubleshooting.md Normal file
View file

@ -0,0 +1,6 @@
# Troubleshooting Common Issues
- Weird macro error with a very tall call stack: Restart Visual Studio Code
- `Get Thread Context Failed`: Turn off other expensive applications while building
- `Type not found: T1`: This is thrown by `json2object`, make sure the data type of `@:default` is correct.
- NOTE: `flixel.util.typeLimit.OneOfTwo` isn't supported.

View file

@ -95,8 +95,10 @@
},
{
"name": "json2object",
"type": "haxelib",
"version": "3.11.0"
"type": "git",
"dir": null,
"ref": "429986134031cbb1980f09d0d3d642b4b4cbcd6a",
"url": "https://github.com/elnabo/json2object"
},
{
"name": "lime",
@ -158,4 +160,4 @@
"version": "0.11.0"
}
]
}
}

View file

@ -4,7 +4,7 @@ import funkin.util.Constants;
import flixel.util.FlxSignal;
import flixel.math.FlxMath;
import funkin.play.song.Song.SongDifficulty;
import funkin.play.song.SongData.SongTimeChange;
import funkin.data.song.SongData.SongTimeChange;
/**
* A core class which handles musical timing throughout the game,

View file

@ -37,7 +37,7 @@ class DialogueBox extends FlxSpriteGroup
{
super();
switch (PlayState.instance.currentSong.songId.toLowerCase())
switch (PlayState.instance.currentSong.id.toLowerCase())
{
case 'senpai':
FlxG.sound.playMusic(Paths.music('Lunchbox'), 0);
@ -78,7 +78,7 @@ class DialogueBox extends FlxSpriteGroup
box = new FlxSprite(-20, 45);
var hasDialog:Bool = false;
switch (PlayState.instance.currentSong.songId.toLowerCase())
switch (PlayState.instance.currentSong.id.toLowerCase())
{
case 'senpai':
hasDialog = true;
@ -150,8 +150,8 @@ class DialogueBox extends FlxSpriteGroup
override function update(elapsed:Float):Void
{
// HARD CODING CUZ IM STUPDI
if (PlayState.instance.currentSong.songId.toLowerCase() == 'roses') portraitLeft.visible = false;
if (PlayState.instance.currentSong.songId.toLowerCase() == 'thorns')
if (PlayState.instance.currentSong.id.toLowerCase() == 'roses') portraitLeft.visible = false;
if (PlayState.instance.currentSong.id.toLowerCase() == 'thorns')
{
portraitLeft.color = FlxColor.BLACK;
swagDialogue.color = FlxColor.WHITE;
@ -187,8 +187,8 @@ class DialogueBox extends FlxSpriteGroup
{
isEnding = true;
if (PlayState.instance.currentSong.songId.toLowerCase() == 'senpai'
|| PlayState.instance.currentSong.songId.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0);
if (PlayState.instance.currentSong.id.toLowerCase() == 'senpai'
|| PlayState.instance.currentSong.id.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0);
new FlxTimer().start(0.2, function(tmr:FlxTimer) {
box.alpha -= 1 / 5;

View file

@ -20,6 +20,7 @@ import flixel.text.FlxText;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.util.FlxColor;
import funkin.data.song.SongRegistry;
import flixel.util.FlxSpriteUtil;
import flixel.util.FlxTimer;
import funkin.Controls.Control;
@ -30,7 +31,6 @@ import funkin.freeplayStuff.LetterSort;
import funkin.freeplayStuff.SongMenuItem;
import funkin.play.HealthIcon;
import funkin.play.PlayState;
import funkin.play.song.SongData.SongDataParser;
import funkin.shaderslmfao.AngleMask;
import funkin.shaderslmfao.PureColor;
import funkin.shaderslmfao.StrokeShader;
@ -843,7 +843,8 @@ class FreeplayState extends MusicBeatSubState
}*/
PlayStatePlaylist.isStoryMode = false;
var targetSong:Song = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase());
var songId:String = songs[curSelected].songName.toLowerCase();
var targetSong:Song = SongRegistry.instance.fetchEntry(songId);
var targetDifficulty:String = switch (curDifficulty)
{
case 0:

View file

@ -17,11 +17,11 @@ import funkin.play.PlayStatePlaylist;
import openfl.display.BitmapData;
import funkin.data.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.play.event.SongEventData.SongEventParser;
import funkin.data.event.SongEventData.SongEventParser;
import funkin.play.cutscene.dialogue.ConversationDataParser;
import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
import funkin.play.cutscene.dialogue.SpeakerDataParser;
import funkin.play.song.SongData.SongDataParser;
import funkin.data.song.SongRegistry;
import funkin.play.stage.StageData.StageDataParser;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.modding.module.ModuleHandler;
@ -197,13 +197,13 @@ class InitState extends FlxState
// NOTE: Registries and data parsers must be imported and not referenced with fully qualified names,
// to ensure build macros work properly.
SongRegistry.instance.loadEntries();
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
SongEventParser.loadEventCache();
ConversationDataParser.loadConversationCache();
DialogueBoxDataParser.loadDialogueBoxCache();
SpeakerDataParser.loadSpeakerCache();
SongDataParser.loadSongCache();
StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache();
ModuleHandler.buildModuleCallbacks();
@ -276,7 +276,7 @@ class InitState extends FlxState
*/
function startSong(songId:String, difficultyId:String = 'normal'):Void
{
var songData:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
var songData:funkin.play.song.Song = funkin.data.song.SongRegistry.instance.fetchEntry(songId);
if (songData == null)
{
@ -312,7 +312,7 @@ class InitState extends FlxState
var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift();
var targetSong:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(targetSongId);
var targetSong:funkin.play.song.Song = SongRegistry.instance.fetchEntry(targetSongId);
LoadingState.loadAndSwitchState(new funkin.play.PlayState(
{

View file

@ -159,7 +159,7 @@ class LoadingState extends MusicBeatState
static function getSongPath():String
{
return Paths.inst(PlayState.instance.currentSong.songId);
return Paths.inst(PlayState.instance.currentSong.id);
}
inline static public function loadAndSwitchState(nextState:FlxState, shouldStopMusic = false):Void

View file

@ -10,7 +10,7 @@ import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.util.FlxColor;
import funkin.play.PlayState;
import funkin.play.song.SongData.SongDataParser;
import funkin.data.song.SongRegistry;
class PauseSubState extends MusicBeatSubState
{
@ -197,7 +197,7 @@ class PauseSubState extends MusicBeatSubState
regenMenu();
case 'EASY' | 'NORMAL' | 'HARD' | 'ERECT':
PlayState.instance.currentSong = SongDataParser.fetchSong(PlayState.instance.currentSong.songId.toLowerCase());
PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase());
PlayState.instance.currentDifficulty = daSelected.toLowerCase();

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

@ -1,7 +1,8 @@
package funkin.play.event;
package funkin.data.event;
import funkin.play.event.SongEventData.SongEventSchema;
import funkin.play.song.SongData.SongEventData;
import funkin.play.event.SongEvent;
import funkin.data.event.SongEventData.SongEventSchema;
import funkin.data.song.SongData.SongEventData;
import funkin.util.macro.ClassMacro;
import funkin.play.event.ScriptedSongEvent;

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

@ -1,8 +1,8 @@
package funkin.play.song;
package funkin.data.song;
import flixel.util.FlxSort;
import funkin.play.song.SongData.SongEventData;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.util.ClipboardUtil;
import funkin.util.SerializerUtil;

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

View file

@ -3,18 +3,19 @@ package funkin.modding;
import funkin.util.macro.ClassMacro;
import funkin.modding.module.ModuleHandler;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.song.SongData;
import funkin.data.song.SongData;
import funkin.play.stage.StageData;
import polymod.Polymod;
import polymod.backends.PolymodAssets.PolymodAssetType;
import polymod.format.ParseRules.TextFileFormat;
import funkin.play.event.SongEventData.SongEventParser;
import funkin.data.event.SongEventData.SongEventParser;
import funkin.util.FileUtil;
import funkin.data.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.play.cutscene.dialogue.ConversationDataParser;
import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
import funkin.play.cutscene.dialogue.SpeakerDataParser;
import funkin.data.song.SongRegistry;
class PolymodHandler
{
@ -290,13 +291,13 @@ class PolymodHandler
// These MUST be imported at the top of the file and not referred to by fully qualified name,
// to ensure build macros work properly.
SongRegistry.instance.loadEntries();
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
SongEventParser.loadEventCache();
ConversationDataParser.loadConversationCache();
DialogueBoxDataParser.loadDialogueBoxCache();
SpeakerDataParser.loadSpeakerCache();
SongDataParser.loadSongCache();
StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache();
ModuleHandler.loadModuleCache();

View file

@ -1,6 +1,6 @@
package funkin.modding.events;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
import flixel.FlxState;
import flixel.FlxSubState;
import funkin.play.notes.NoteSprite;
@ -435,9 +435,9 @@ class SongEventScriptEvent extends ScriptEvent
* The note associated with this event.
* You cannot replace it, but you can edit it.
*/
public var event(default, null):funkin.play.song.SongData.SongEventData;
public var event(default, null):funkin.data.song.SongData.SongEventData;
public function new(event:funkin.play.song.SongData.SongEventData):Void
public function new(event:funkin.data.song.SongData.SongEventData):Void
{
super(ScriptEvent.SONG_EVENT, true);
this.event = event;

View file

@ -35,7 +35,7 @@ import funkin.play.cutscene.dialogue.Conversation;
import funkin.play.cutscene.dialogue.ConversationDataParser;
import funkin.play.cutscene.VanillaCutscenes;
import funkin.play.cutscene.VideoCutscene;
import funkin.play.event.SongEventData.SongEventParser;
import funkin.data.event.SongEventData.SongEventParser;
import funkin.play.notes.NoteSprite;
import funkin.play.notes.NoteDirection;
import funkin.play.notes.Strumline;
@ -43,10 +43,10 @@ import funkin.play.notes.SustainTrail;
import funkin.play.scoring.Scoring;
import funkin.NoteSplash;
import funkin.play.song.Song;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.song.SongData.SongEventData;
import funkin.play.song.SongData.SongNoteData;
import funkin.play.song.SongData.SongPlayableChar;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.play.stage.Stage;
import funkin.play.stage.StageData.StageDataParser;
import funkin.ui.PopUpStuff;
@ -630,7 +630,7 @@ class PlayState extends MusicBeatSubState
startingSong = true;
// TODO: We hardcoded the transition into Winter Horrorland. Do this with a ScriptedSong instead.
if ((currentSong?.songId ?? '').toLowerCase() == 'winter-horrorland')
if ((currentSong?.id ?? '').toLowerCase() == 'winter-horrorland')
{
// VanillaCutscenes will call startCountdown later.
VanillaCutscenes.playHorrorStartCutscene();
@ -2495,9 +2495,9 @@ class PlayState extends MusicBeatSubState
if (currentSong != null && currentSong.validScore)
{
// crackhead double thingie, sets whether was new highscore, AND saves the song!
Highscore.tallies.isNewHighscore = Highscore.saveScoreForDifficulty(currentSong.songId, songScore, currentDifficulty);
Highscore.tallies.isNewHighscore = Highscore.saveScoreForDifficulty(currentSong.id, songScore, currentDifficulty);
Highscore.saveCompletionForDifficulty(currentSong.songId, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, currentDifficulty);
Highscore.saveCompletionForDifficulty(currentSong.id, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, currentDifficulty);
}
if (PlayStatePlaylist.isStoryMode)
@ -2549,7 +2549,7 @@ class PlayState extends MusicBeatSubState
vocals.stop();
// TODO: Softcode this cutscene.
if (currentSong.songId == 'eggnog')
if (currentSong.id == 'eggnog')
{
var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom,
-FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK);
@ -2560,7 +2560,7 @@ class PlayState extends MusicBeatSubState
FlxG.sound.play(Paths.sound('Lights_Shut_off'), function() {
// no camFollow so it centers on horror tree
var targetSong:Song = SongDataParser.fetchSong(targetSongId);
var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId);
// Load and cache the song's charts.
// TODO: Do this in the loading state.
targetSong.cacheCharts(true);
@ -2577,7 +2577,7 @@ class PlayState extends MusicBeatSubState
}
else
{
var targetSong:Song = SongDataParser.fetchSong(targetSongId);
var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId);
// Load and cache the song's charts.
// TODO: Do this in the loading state.
targetSong.cacheCharts(true);

View file

@ -143,7 +143,7 @@ class ResultState extends MusicBeatSubState
}
else
{
songName.text += PlayState.instance.currentSong.songId;
songName.text += PlayState.instance.currentSong.id;
}
songName.letterSpacing = -15;

View file

@ -1,9 +1,12 @@
package funkin.play.event;
import funkin.play.song.SongData;
// Data from the chart
import funkin.data.song.SongData;
import funkin.data.song.SongData.SongEventData;
// Data from the event schema
import funkin.play.event.SongEvent;
import funkin.play.event.SongEventData.SongEventFieldType;
import funkin.play.event.SongEventData.SongEventSchema;
import funkin.data.event.SongEventData.SongEventSchema;
import funkin.data.event.SongEventData.SongEventFieldType;
/**
* This class represents a handler for a type of song event.

View file

@ -2,10 +2,13 @@ package funkin.play.event;
import flixel.FlxSprite;
import funkin.play.character.BaseCharacter;
// Data from the chart
import funkin.data.song.SongData;
import funkin.data.song.SongData.SongEventData;
// Data from the event schema
import funkin.play.event.SongEvent;
import funkin.play.event.SongEventData.SongEventFieldType;
import funkin.play.event.SongEventData.SongEventSchema;
import funkin.play.song.SongData;
import funkin.data.event.SongEventData.SongEventSchema;
import funkin.data.event.SongEventData.SongEventFieldType;
class PlayAnimationSongEvent extends SongEvent
{

View file

@ -3,10 +3,13 @@ package funkin.play.event;
import flixel.tweens.FlxTween;
import flixel.FlxCamera;
import flixel.tweens.FlxEase;
// Data from the chart
import funkin.data.song.SongData;
import funkin.data.song.SongData.SongEventData;
// Data from the event schema
import funkin.play.event.SongEvent;
import funkin.play.song.SongData;
import funkin.play.event.SongEventData;
import funkin.play.event.SongEventData.SongEventFieldType;
import funkin.data.event.SongEventData.SongEventSchema;
import funkin.data.event.SongEventData.SongEventFieldType;
/**
* This class represents a handler for configuring camera bop intensity and rate.

View file

@ -1,7 +1,7 @@
package funkin.play.event;
import funkin.play.song.SongData.SongEventData;
import funkin.play.event.SongEventData.SongEventSchema;
import funkin.data.song.SongData.SongEventData;
import funkin.data.event.SongEventData.SongEventSchema;
/**
* This class represents a handler for a type of song event.

View file

@ -3,10 +3,13 @@ package funkin.play.event;
import flixel.tweens.FlxTween;
import flixel.FlxCamera;
import flixel.tweens.FlxEase;
// Data from the chart
import funkin.data.song.SongData;
import funkin.data.song.SongData.SongEventData;
// Data from the event schema
import funkin.play.event.SongEvent;
import funkin.play.song.SongData;
import funkin.play.event.SongEventData;
import funkin.play.event.SongEventData.SongEventFieldType;
import funkin.data.event.SongEventData.SongEventFieldType;
import funkin.data.event.SongEventData.SongEventSchema;
/**
* This class represents a handler for camera zoom events.
@ -76,8 +79,7 @@ class ZoomCameraSongEvent extends SongEvent
return;
}
FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.stepLengthMs * duration / 1000),
{ease: easeFunction});
FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.stepLengthMs * duration / 1000), {ease: easeFunction});
}
}

View file

@ -1,6 +1,6 @@
package funkin.play.notes;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
import funkin.play.notes.notestyle.NoteStyle;
import flixel.graphics.frames.FlxAtlasFrames;
import flixel.FlxSprite;

View file

@ -11,7 +11,7 @@ import funkin.play.notes.NoteHoldCover;
import funkin.play.notes.NoteSplash;
import funkin.play.notes.NoteSprite;
import funkin.play.notes.SustainTrail;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
import funkin.ui.PreferencesMenu;
import funkin.util.SortUtil;

View file

@ -2,7 +2,7 @@ package funkin.play.notes;
import funkin.play.notes.notestyle.NoteStyle;
import funkin.play.notes.NoteDirection;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
import flixel.util.FlxDirectionFlags;
import flixel.FlxSprite;
import flixel.graphics.FlxGraphic;

View file

@ -104,7 +104,8 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary());
if (noteFrames == null) {
if (noteFrames == null)
{
throw 'Could not load note frames for note style: $id';
}
@ -139,13 +140,13 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
function buildNoteAnimations(target:NoteSprite):Void
{
var leftData:AnimationData = fetchNoteAnimationData(LEFT);
target.animation.addByPrefix('purpleScroll', leftData.prefix);
target.animation.addByPrefix('purpleScroll', leftData.prefix, leftData.frameRate, leftData.looped, leftData.flipX, leftData.flipY);
var downData:AnimationData = fetchNoteAnimationData(DOWN);
target.animation.addByPrefix('blueScroll', downData.prefix);
target.animation.addByPrefix('blueScroll', downData.prefix, downData.frameRate, downData.looped, downData.flipX, downData.flipY);
var upData:AnimationData = fetchNoteAnimationData(UP);
target.animation.addByPrefix('greenScroll', upData.prefix);
target.animation.addByPrefix('greenScroll', upData.prefix, upData.frameRate, upData.looped, upData.flipX, upData.flipY);
var rightData:AnimationData = fetchNoteAnimationData(RIGHT);
target.animation.addByPrefix('redScroll', rightData.prefix);
target.animation.addByPrefix('redScroll', rightData.prefix, rightData.frameRate, rightData.looped, rightData.flipX, rightData.flipY);
}
function fetchNoteAnimationData(dir:NoteDirection):AnimationData
@ -302,7 +303,7 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
return 'NoteStyle($id)';
}
public function _fetchData(id:String):Null<NoteStyleData>
static function _fetchData(id:String):Null<NoteStyleData>
{
return NoteStyleRegistry.instance.parseEntryDataWithMigration(id, NoteStyleRegistry.instance.fetchEntryVersion(id));
}

View file

@ -5,14 +5,16 @@ import openfl.utils.Assets;
import funkin.modding.events.ScriptEvent;
import funkin.modding.IScriptedClass;
import funkin.audio.VoicesGroup;
import funkin.play.song.SongData.SongChartData;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.song.SongData.SongEventData;
import funkin.play.song.SongData.SongMetadata;
import funkin.play.song.SongData.SongNoteData;
import funkin.play.song.SongData.SongPlayableChar;
import funkin.play.song.SongData.SongTimeChange;
import funkin.play.song.SongData.SongTimeFormat;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.data.song.SongData.SongTimeChange;
import funkin.data.song.SongData.SongTimeFormat;
import funkin.data.IRegistryEntry;
/**
* This is a data structure managing information about the current song.
@ -23,9 +25,26 @@ import funkin.play.song.SongData.SongTimeFormat;
* It also receives script events; scripted classes which extend this class
* can be used to perform custom gameplay behaviors only on specific songs.
*/
class Song implements IPlayStateScriptedClass
@:nullSafety
class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMetadata>
{
public final songId:String;
public static final DEFAULT_SONGNAME:String = "Unknown";
public static final DEFAULT_ARTIST:String = "Unknown";
public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS;
public static final DEFAULT_DIVISIONS:Null<Int> = null;
public static final DEFAULT_LOOPED:Bool = false;
public static final DEFAULT_STAGE:String = "mainStage";
public static final DEFAULT_SCROLLSPEED:Float = 1.0;
public final id:String;
/**
* Song metadata as parsed from the JSON file.
* This is the data for the `default` variation specifically,
* and is needed for the IRegistryEntry interface.
* Will only be null if the song data could not be loaded.
*/
public final _data:Null<SongMetadata>;
final _metadata:Array<SongMetadata>;
@ -39,33 +58,56 @@ class Song implements IPlayStateScriptedClass
var difficultyIds:Array<String>;
public var songName(get, never):String;
function get_songName():String
{
if (_data != null) return _data?.songName ?? DEFAULT_SONGNAME;
if (_metadata.length > 0) return _metadata[0]?.songName ?? DEFAULT_SONGNAME;
return DEFAULT_SONGNAME;
}
public var songArtist(get, never):String;
function get_songArtist():String
{
if (_data != null) return _data?.artist ?? DEFAULT_ARTIST;
if (_metadata.length > 0) return _metadata[0]?.artist ?? DEFAULT_ARTIST;
return DEFAULT_ARTIST;
}
/**
* @param id The ID of the song to load.
* @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded.
*/
public function new(id:String, ignoreErrors:Bool = false)
public function new(id:String)
{
this.songId = id;
this.id = id;
variations = [];
difficultyIds = [];
difficulties = new Map<String, SongDifficulty>();
try
_data = _fetchData(id);
_metadata = _data == null ? [] : [_data];
for (meta in fetchVariationMetadata(id))
_metadata.push(meta);
if (_metadata.length == 0)
{
_metadata = SongDataParser.loadSongMetadata(songId);
}
catch (e)
{
_metadata = [];
trace('[WARN] Could not find song data for songId: $id');
return;
}
if (_metadata.length == 0 && !ignoreErrors)
{
throw 'Could not find song data for songId: $songId';
}
else
variations.clear();
variations.push('default');
if (_data != null && _data.playData != null)
{
for (vari in _data.playData.songVariations)
variations.push(vari);
populateFromMetadata();
}
}
@ -74,7 +116,7 @@ class Song implements IPlayStateScriptedClass
public static function buildRaw(songId:String, metadata:Array<SongMetadata>, variations:Array<String>, charts:Map<String, SongChartData>,
validScore:Bool = false):Song
{
var result:Song = new Song(songId, true);
var result:Song = new Song(songId);
result._metadata.clear();
for (meta in metadata)
@ -112,6 +154,8 @@ class Song implements IPlayStateScriptedClass
// Variations may have different artist, time format, generatedBy, etc.
for (metadata in _metadata)
{
if (metadata == null || metadata.playData == null) continue;
// There may be more difficulties in the chart file than in the metadata,
// (i.e. non-playable charts like the one used for Pico on the speaker in Stress)
// but all the difficulties in the metadata must be in the chart file.
@ -134,15 +178,16 @@ class Song implements IPlayStateScriptedClass
difficulty.stage = metadata.playData.stage;
// difficulty.noteSkin = metadata.playData.noteSkin;
difficulties.set(diffId, difficulty);
difficulty.chars = new Map<String, SongPlayableChar>();
if (metadata.playData.playableChars == null) continue;
for (charId in metadata.playData.playableChars.keys())
{
var char = metadata.playData.playableChars.get(charId);
var char:Null<SongPlayableChar> = metadata.playData.playableChars.get(charId);
if (char == null) continue;
difficulty.chars.set(charId, char);
}
difficulties.set(diffId, difficulty);
}
}
}
@ -157,11 +202,14 @@ class Song implements IPlayStateScriptedClass
clearCharts();
}
trace('Caching ${variations.length} chart files for song $songId');
trace('Caching ${variations.length} chart files for song $id');
for (variation in variations)
{
var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation);
applyChartData(chartData, variation);
var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryChartVersion(id, variation);
if (version == null) continue;
var chart:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataWithMigration(id, version, variation);
if (chart == null) continue;
applyChartData(chart, variation);
}
trace('Done caching charts.');
}
@ -181,8 +229,8 @@ class Song implements IPlayStateScriptedClass
difficulties.set(diffId, difficulty);
}
// Add the chart data to the difficulty.
difficulty.notes = chartData.notes.get(diffId);
difficulty.scrollSpeed = chartData.getScrollSpeed(diffId);
difficulty.notes = chartNotes.get(diffId) ?? [];
difficulty.scrollSpeed = chartData.getScrollSpeed(diffId) ?? 1.0;
difficulty.events = chartData.events;
}
@ -193,7 +241,7 @@ class Song implements IPlayStateScriptedClass
* @param diffId The difficulty ID, such as `easy` or `hard`.
* @return The difficulty data.
*/
public inline function getDifficulty(diffId:String = null):SongDifficulty
public inline function getDifficulty(?diffId:String):Null<SongDifficulty>
{
if (diffId == null) diffId = difficulties.keys().array()[0];
@ -223,9 +271,11 @@ class Song implements IPlayStateScriptedClass
public function toString():String
{
return 'Song($songId)';
return 'Song($id)';
}
public function destroy():Void {}
public function onPause(event:PauseScriptEvent):Void {};
public function onResume(event:ScriptEvent):Void {};
@ -265,6 +315,27 @@ class Song implements IPlayStateScriptedClass
public function onDestroy(event:ScriptEvent):Void {};
public function onUpdate(event:UpdateScriptEvent):Void {};
static function _fetchData(id:String):Null<SongMetadata>
{
trace('Fetching song metadata for $id');
var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id);
if (version == null) return null;
return SongRegistry.instance.parseEntryMetadataWithMigration(id, '', version);
}
function fetchVariationMetadata(id:String):Array<SongMetadata>
{
var result:Array<SongMetadata> = [];
for (vari in variations)
{
var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id, vari);
if (version == null) continue;
var meta:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version);
if (meta != null) result.push(meta);
}
return result;
}
}
class SongDifficulty
@ -299,7 +370,7 @@ class SongDifficulty
public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT;
public var divisions:Null<Int> = SongValidator.DEFAULT_DIVISIONS;
public var looped:Bool = SongValidator.DEFAULT_LOOPED;
public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY;
public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY;
public var timeChanges:Array<SongTimeChange> = [];
@ -351,18 +422,18 @@ class SongDifficulty
var currentPlayer:Null<SongPlayableChar> = getPlayableChar(currentPlayerId);
if (currentPlayer != null)
{
FlxG.sound.cache(Paths.inst(this.song.songId, currentPlayer.inst));
FlxG.sound.cache(Paths.inst(this.song.id, currentPlayer.inst));
}
else
{
FlxG.sound.cache(Paths.inst(this.song.songId));
FlxG.sound.cache(Paths.inst(this.song.id));
}
}
public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void
{
var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
FlxG.sound.playMusic(Paths.inst(this.song.songId, suffix), volume, looped);
FlxG.sound.playMusic(Paths.inst(this.song.id, suffix), volume, looped);
}
/**
@ -388,7 +459,7 @@ class SongDifficulty
var playableCharData:SongPlayableChar = getPlayableChar(id);
if (playableCharData == null)
{
trace('Could not find playable char $id for song ${this.song.songId}');
trace('Could not find playable char $id for song ${this.song.id}');
return [];
}
@ -398,24 +469,24 @@ class SongDifficulty
// For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`.
var playerId:String = id;
var voicePlayer:String = Paths.voices(this.song.songId, '-$id$suffix');
var voicePlayer:String = Paths.voices(this.song.id, '-$id$suffix');
while (voicePlayer != null && !Assets.exists(voicePlayer))
{
// Remove the last suffix.
// For example, bf-car becomes bf.
playerId = playerId.split('-').slice(0, -1).join('-');
// Try again.
voicePlayer = playerId == '' ? null : Paths.voices(this.song.songId, '-${playerId}$suffix');
voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
}
var opponentId:String = playableCharData.opponent;
var voiceOpponent:String = Paths.voices(this.song.songId, '-${opponentId}$suffix');
var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix');
while (voiceOpponent != null && !Assets.exists(voiceOpponent))
{
// Remove the last suffix.
opponentId = opponentId.split('-').slice(0, -1).join('-');
// Try again.
voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.songId, '-${opponentId}$suffix');
voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix');
}
var result:Array<String> = [];
@ -424,7 +495,7 @@ class SongDifficulty
if (voicePlayer == null && voiceOpponent == null)
{
// Try to use `Voices.ogg` if no other voices are found.
if (Assets.exists(Paths.voices(this.song.songId, ''))) result.push(Paths.voices(this.song.songId, '$suffix'));
if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix'));
}
return result;
}
@ -442,7 +513,7 @@ class SongDifficulty
if (voiceList.length == 0)
{
trace('Could not find any voices for song ${this.song.songId}');
trace('Could not find any voices for song ${this.song.id}');
return result;
}

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,11 @@
package funkin.play.song;
import funkin.play.song.formats.FNFLegacy;
import funkin.play.song.SongData.SongChartData;
import funkin.play.song.SongData.SongEventData;
import funkin.play.song.SongData.SongMetadata;
import funkin.play.song.SongData.SongNoteData;
import funkin.play.song.SongData.SongPlayableChar;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.util.VersionUtil;
class SongMigrator
@ -176,7 +176,7 @@ class SongMigrator
songMetadata.playData.songVariations = [];
// Set the song's song variations.
songMetadata.playData.playableChars = {};
songMetadata.playData.playableChars = [];
try
{
Reflect.setField(songMetadata.playData.playableChars, songData.song.player1, new SongPlayableChar('', songData.song.player2));
@ -203,7 +203,7 @@ class SongMigrator
var songData:FNFLegacy = cast jsonData;
var songChartData:SongChartData = new SongChartData(1.0, [], []);
var songChartData:SongChartData = new SongChartData(["normal" => 1.0], [], ["normal" => []]);
var songEventsEmpty:Bool = songChartData.getEvents() == null || songChartData.getEvents().length == 0;
if (songEventsEmpty) songChartData.setEvents(migrateSongEventDataFromLegacy(songData.song.notes));

View file

@ -1,7 +1,7 @@
package funkin.play.song;
import funkin.play.song.SongData.SongChartData;
import funkin.play.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import funkin.util.SerializerUtil;
import lime.utils.Bytes;
import openfl.events.Event;

View file

@ -1,10 +1,11 @@
package funkin.play.song;
import funkin.play.song.SongData.SongChartData;
import funkin.play.song.SongData.SongMetadata;
import funkin.play.song.SongData.SongPlayData;
import funkin.play.song.SongData.SongTimeChange;
import funkin.play.song.SongData.SongTimeFormat;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongPlayData;
import funkin.data.song.SongData.SongTimeChange;
import funkin.data.song.SongData.SongTimeFormat;
/**
* For SongMetadata and SongChartData objects,
@ -59,7 +60,7 @@ class SongValidator
}
if (input.generatedBy == null)
{
input.generatedBy = DEFAULT_GENERATEDBY;
input.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
}
input.timeChanges = validateTimeChanges(input.timeChanges, songId);

View file

@ -1,8 +1,8 @@
package funkin.ui.debug.charting;
import funkin.play.song.SongData.SongEventData;
import funkin.play.song.SongData.SongNoteData;
import funkin.play.song.SongDataUtils;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongDataUtils;
using Lambda;

View file

@ -3,8 +3,8 @@ package funkin.ui.debug.charting;
import funkin.play.character.CharacterData;
import funkin.util.Constants;
import funkin.util.SerializerUtil;
import funkin.play.song.SongData.SongChartData;
import funkin.play.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import flixel.util.FlxTimer;
import funkin.util.SortUtil;
import funkin.input.Cursor;
@ -13,9 +13,9 @@ import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.song.Song;
import funkin.play.song.SongMigrator;
import funkin.play.song.SongValidator;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.song.SongData.SongPlayableChar;
import funkin.play.song.SongData.SongTimeChange;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.data.song.SongData.SongTimeChange;
import funkin.util.FileUtil;
import haxe.io.Path;
import haxe.ui.components.Button;
@ -112,23 +112,17 @@ class ChartEditorDialogHandler
var splashTemplateContainer:Null<VBox> = dialog.findComponent('splashTemplateContainer', VBox);
if (splashTemplateContainer == null) throw 'Could not locate splashTemplateContainer in Welcome dialog';
var songList:Array<String> = SongDataParser.listSongIds();
var songList:Array<String> = SongRegistry.instance.listEntryIds();
songList.sort(SortUtil.alphabetically);
for (targetSongId in songList)
{
var songData:Null<Song> = SongDataParser.fetchSong(targetSongId);
var songData:Null<Song> = SongRegistry.instance.fetchEntry(targetSongId);
if (songData == null) continue;
var diffNormal:Null<SongDifficulty> = songData.getDifficulty('normal');
var songName:Null<String> = diffNormal?.songName;
if (songName == null)
{
var diffDefault:Null<SongDifficulty> = songData.getDifficulty();
songName = diffDefault?.songName;
}
if (songName == null)
var songName:Null<String> = songData.getDifficulty('normal')?.songName;
if (songName == null) songName = songData.getDifficulty()?.songName;
if (songName == null) // Still null?
{
trace('[WARN] Could not fetch song name for ${targetSongId}');
continue;
@ -508,9 +502,9 @@ class ChartEditorDialogHandler
if (dialogNoteSkin == null) throw 'Could not locate dialogNoteSkin DropDown in Song Metadata dialog';
dialogNoteSkin.onChange = function(event:UIEvent) {
if (event.data.id == null) return;
state.currentSongMetadata.playData.noteSkin = event.data.id;
state.currentSongNoteSkin = event.data.id;
};
state.currentSongMetadata.playData.noteSkin = 'funkin';
state.currentSongNoteSkin = 'funkin';
var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper);
if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Song Metadata dialog';
@ -520,7 +514,7 @@ class ChartEditorDialogHandler
var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
if (timeChanges == null || timeChanges.length == 0)
{
timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])];
timeChanges = [new SongTimeChange(0, event.value)];
}
else
{
@ -543,7 +537,7 @@ class ChartEditorDialogHandler
};
// Empty the character list.
state.currentSongMetadata.playData.playableChars = {};
state.currentSongMetadata.playData.playableChars = [];
// Add at least one character group with no Remove button.
dialogCharGrid.addComponent(buildCharGroup(state, 'bf'));
@ -558,7 +552,8 @@ class ChartEditorDialogHandler
{
var groupKey:String = key;
var getCharData:Void->SongPlayableChar = function() {
var getCharData:Void->Null<SongPlayableChar> = function():Null<SongPlayableChar> {
if (state.currentSongMetadata.playData == null) return null;
if (groupKey == null) groupKey = 'newChar${state.currentSongMetadata.playData.playableChars.keys().count()}';
var result = state.currentSongMetadata.playData.playableChars.get(groupKey);
@ -570,46 +565,53 @@ class ChartEditorDialogHandler
return result;
}
var moveCharGroup:String->Void = function(target:String) {
var charData = getCharData();
var moveCharGroup:String->Void = function(target:String):Void {
var charData:Null<SongPlayableChar> = getCharData();
if (charData == null) return;
if (state.currentSongMetadata.playData.playableChars == null) return;
state.currentSongMetadata.playData.playableChars.remove(groupKey);
state.currentSongMetadata.playData.playableChars.set(target, charData);
groupKey = target;
}
var removeGroup:Void->Void = function() {
var removeGroup:Void->Void = function():Void {
if (state?.currentSongMetadata?.playData?.playableChars == null) return;
state.currentSongMetadata.playData.playableChars.remove(groupKey);
if (removeFunc != null) removeFunc();
}
var charData:SongPlayableChar = getCharData();
var charData:Null<SongPlayableChar> = getCharData();
var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT);
var charGroupPlayer:Null<DropDown> = charGroup.findComponent('charGroupPlayer', DropDown);
if (charGroupPlayer == null) throw 'Could not locate charGroupPlayer DropDown in Song Metadata dialog';
charGroupPlayer.onChange = function(event:UIEvent) {
charGroupPlayer.onChange = function(event:UIEvent):Void {
if (charData != null) return;
charGroup.text = event.data.text;
moveCharGroup(event.data.id);
};
var charGroupOpponent:Null<DropDown> = charGroup.findComponent('charGroupOpponent', DropDown);
if (charGroupOpponent == null) throw 'Could not locate charGroupOpponent DropDown in Song Metadata dialog';
charGroupOpponent.onChange = function(event:UIEvent) {
charGroupOpponent.onChange = function(event:UIEvent):Void {
if (charData == null) return;
charData.opponent = event.data.id;
};
charGroupOpponent.value = getCharData().opponent;
charGroupOpponent.value = charData.opponent;
var charGroupGirlfriend:Null<DropDown> = charGroup.findComponent('charGroupGirlfriend', DropDown);
if (charGroupGirlfriend == null) throw 'Could not locate charGroupGirlfriend DropDown in Song Metadata dialog';
charGroupGirlfriend.onChange = function(event:UIEvent) {
charGroupGirlfriend.onChange = function(event:UIEvent):Void {
if (charData == null) return;
charData.girlfriend = event.data.id;
};
charGroupGirlfriend.value = getCharData().girlfriend;
charGroupGirlfriend.value = charData.girlfriend;
var charGroupRemove:Null<Button> = charGroup.findComponent('charGroupRemove', Button);
if (charGroupRemove == null) throw 'Could not locate charGroupRemove Button in Song Metadata dialog';
charGroupRemove.onClick = function(event:UIEvent) {
charGroupRemove.onClick = function(event:UIEvent):Void {
removeGroup();
};
@ -630,7 +632,8 @@ class ChartEditorDialogHandler
for (charKey in state.currentSongMetadata.playData.playableChars.keys())
{
var charData:SongPlayableChar = state.currentSongMetadata.playData.playableChars.get(charKey);
var charData:Null<SongPlayableChar> = state.currentSongMetadata.playData.playableChars.get(charKey);
if (charData == null) continue;
charIdsForVocals.push(charKey);
if (charData.opponent != null) charIdsForVocals.push(charData.opponent);
}

View file

@ -1,6 +1,6 @@
package funkin.ui.debug.charting;
import funkin.play.event.SongEventData.SongEventParser;
import funkin.data.event.SongEventData.SongEventParser;
import flixel.graphics.frames.FlxAtlasFrames;
import openfl.display.BitmapData;
import openfl.utils.Assets;
@ -10,7 +10,7 @@ import flixel.FlxSprite;
import flixel.graphics.frames.FlxFramesCollection;
import flixel.graphics.frames.FlxTileFrames;
import flixel.math.FlxPoint;
import funkin.play.song.SongData.SongEventData;
import funkin.data.song.SongData.SongEventData;
/**
* A event sprite that can be used to display a song event in a chart.

View file

@ -8,7 +8,7 @@ import flixel.graphics.frames.FlxFramesCollection;
import flixel.graphics.frames.FlxTileFrames;
import flixel.math.FlxPoint;
import funkin.play.notes.SustainTrail;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
/**
* A hold note sprite that can be used to display a note in a chart.

View file

@ -1,7 +1,7 @@
package funkin.ui.debug.charting;
import funkin.play.song.SongData.SongEventData;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import flixel.math.FlxMath;
import flixel.FlxSprite;
import flixel.util.FlxColor;

View file

@ -7,7 +7,7 @@ import flixel.graphics.frames.FlxAtlasFrames;
import flixel.graphics.frames.FlxFrame;
import flixel.graphics.frames.FlxTileFrames;
import flixel.math.FlxPoint;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
/**
* A note sprite that can be used to display a note in a chart.

View file

@ -36,13 +36,13 @@ import funkin.play.notes.NoteSprite;
import funkin.play.notes.Strumline;
import funkin.play.PlayState;
import funkin.play.song.Song;
import funkin.play.song.SongData.SongChartData;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.song.SongData.SongEventData;
import funkin.play.song.SongData.SongMetadata;
import funkin.play.song.SongData.SongNoteData;
import funkin.play.song.SongData.SongPlayableChar;
import funkin.play.song.SongDataUtils;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.data.song.SongDataUtils;
import funkin.ui.debug.charting.ChartEditorCommand;
import funkin.ui.debug.charting.ChartEditorCommand;
import funkin.ui.debug.charting.ChartEditorThemeHandler.ChartEditorTheme;
@ -865,7 +865,7 @@ class ChartEditorState extends HaxeUIState
var result:Null<SongChartData> = songChartData.get(selectedVariation);
if (result == null)
{
result = new SongChartData(1.0, [], []);
result = new SongChartData(["normal" => 1.0], [], ["normal" => []]);
songChartData.set(selectedVariation, result);
}
return result;
@ -4003,7 +4003,7 @@ class ChartEditorState extends HaxeUIState
*/
public function loadSongAsTemplate(songId:String):Void
{
var song:Null<Song> = SongDataParser.fetchSong(songId);
var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
if (song == null) return;
@ -4021,7 +4021,7 @@ class ChartEditorState extends HaxeUIState
var metadataClone = Reflect.copy(metadata);
if (metadataClone != null) songMetadata.set(variation, metadataClone);
songChartData.set(variation, SongDataParser.parseSongChartData(songId, metadata.variation));
songChartData.set(variation, SongRegistry.instance.parseEntryChartData(songId, metadata.variation));
}
loadSong(songMetadata, songChartData);

View file

@ -4,8 +4,8 @@ import haxe.ui.containers.TreeView;
import haxe.ui.containers.TreeViewNode;
import funkin.play.character.BaseCharacter.CharacterType;
import funkin.play.event.SongEvent;
import funkin.play.event.SongEventData;
import funkin.play.song.SongData.SongTimeChange;
import funkin.data.event.SongEventData;
import funkin.data.song.SongData.SongTimeChange;
import funkin.play.song.SongSerializer;
import funkin.ui.haxeui.components.CharacterPlayer;
import haxe.ui.components.Button;
@ -569,9 +569,9 @@ class ChartEditorToolboxHandler
if (inputNoteSkin == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteSkin component.';
inputNoteSkin.onChange = function(event:UIEvent) {
if ((event?.data?.id ?? null) == null) return;
state.currentSongMetadata.playData.noteSkin = event.data.id;
state.currentSongNoteSkin = event.data.id;
};
inputNoteSkin.value = state.currentSongMetadata.playData.noteSkin;
inputNoteSkin.value = state.currentSongNoteSkin;
var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
if (inputBPM == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputBPM component.';
@ -581,7 +581,7 @@ class ChartEditorToolboxHandler
var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
if (timeChanges == null || timeChanges.length == 0)
{
timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])];
timeChanges = [new SongTimeChange(0, event.value)];
}
else
{

View file

@ -4,6 +4,7 @@ import flixel.FlxSprite;
import flixel.util.FlxColor;
import funkin.play.song.Song;
import funkin.data.IRegistryEntry;
import funkin.data.song.SongRegistry;
import funkin.data.level.LevelRegistry;
import funkin.data.level.LevelData;
@ -70,17 +71,20 @@ class Level implements IRegistryEntry<LevelData>
public function getSongDisplayNames(difficulty:String):Array<String>
{
var songList:Array<String> = getSongs() ?? [];
var songNameList:Array<String> = songList.map(function(songId) {
var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
if (song == null) return 'Unknown';
var songDifficulty:SongDifficulty = song.getDifficulty(difficulty);
if (songDifficulty == null) songDifficulty = song.getDifficulty();
var songName:String = songDifficulty?.songName;
return songName ?? 'Unknown';
var songNameList:Array<String> = songList.map(function(songId:String) {
return getSongDisplayName(songId, difficulty);
});
return songNameList;
}
static function getSongDisplayName(songId:String, difficulty:String):String
{
var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
if (song == null) return 'Unknown';
return song.songName;
}
/**
* Whether this level is unlocked. If not, it will be greyed out on the menu and have a lock icon.
* TODO: Change this behavior in a later release.
@ -120,7 +124,7 @@ class Level implements IRegistryEntry<LevelData>
var songList = getSongs();
var firstSongId:String = songList[0];
var firstSong:Song = funkin.play.song.SongData.SongDataParser.fetchSong(firstSongId);
var firstSong:Song = SongRegistry.instance.fetchEntry(firstSongId);
if (firstSong != null)
{
@ -134,7 +138,7 @@ class Level implements IRegistryEntry<LevelData>
for (songIndex in 1...songList.length)
{
var songId:String = songList[songIndex];
var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
var song:Song = SongRegistry.instance.fetchEntry(songId);
if (song == null) continue;
@ -179,7 +183,7 @@ class Level implements IRegistryEntry<LevelData>
return 'Level($id)';
}
public function _fetchData(id:String):Null<LevelData>
static function _fetchData(id:String):Null<LevelData>
{
return LevelRegistry.instance.parseEntryDataWithMigration(id, LevelRegistry.instance.fetchEntryVersion(id));
}

View file

@ -16,8 +16,8 @@ import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.PlayState;
import funkin.play.PlayStatePlaylist;
import funkin.play.song.Song;
import funkin.play.song.SongData.SongMetadata;
import funkin.play.song.SongData.SongDataParser;
import funkin.data.song.SongData.SongMusicData;
import funkin.data.song.SongRegistry;
class StoryMenuState extends MusicBeatState
{
@ -201,8 +201,11 @@ class StoryMenuState extends MusicBeatState
{
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
{
var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu');
Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu');
if (freakyMenuMetadata != null)
{
Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
}
FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
FlxG.sound.music.fadeIn(4, 0, 0.7);
@ -509,7 +512,7 @@ class StoryMenuState extends MusicBeatState
var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift();
var targetSong:Song = SongDataParser.fetchSong(targetSongId);
var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId);
PlayStatePlaylist.campaignId = currentLevel.id;
PlayStatePlaylist.campaignTitle = currentLevel.getTitle();

View file

@ -12,8 +12,8 @@ import flixel.util.FlxTimer;
import funkin.audiovis.SpectogramSprite;
import funkin.shaderslmfao.ColorSwap;
import funkin.shaderslmfao.LeftMaskShader;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.song.SongData.SongMetadata;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongMusicData;
import funkin.shaderslmfao.TitleOutline;
import funkin.ui.AtlasText;
import openfl.Assets;
@ -216,9 +216,11 @@ class TitleState extends MusicBeatState
{
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
{
var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu');
Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu');
if (freakyMenuMetadata != null)
{
Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
}
FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
FlxG.sound.music.fadeIn(4, 0, 0.7);
}

View file

@ -1,5 +1,6 @@
package funkin.util;
import flixel.graphics.frames.FlxFrame;
#if !macro
import flixel.FlxBasic;
import flixel.util.FlxSort;
@ -41,6 +42,16 @@ class SortUtil
return FlxSort.byValues(order, a.noteData.time, b.noteData.time);
}
/**
* Given two FlxFrames, sort their names alphabetically.
*
* @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING`
*/
public static inline function byFrameName(a:FlxFrame, b:FlxFrame)
{
return alphabetically(a.name, b.name);
}
/**
* Sort predicate for sorting strings alphabetically.
* @param a The first string to compare.

View file

@ -13,4 +13,22 @@ class IteratorTools
{
return [for (i in iterator) i];
}
public static function count<T>(iterator:Iterator<T>, ?predicate:(item:T) -> Bool):Int
{
var n = 0;
if (predicate == null)
{
for (_ in iterator)
n++;
}
else
{
for (x in iterator)
if (predicate(x)) n++;
}
return n;
}
}

View file

@ -7,7 +7,7 @@
"note": {
"assetPath": "shared:arrows",
"scale": 1.0,
"isPixel": true,
"isPixel": false,
"data": {
"left": { "prefix": "noteLeft" },
"down": { "prefix": "noteDown" },
@ -19,7 +19,7 @@
"assetPath": "shared:arrows",
"scale": 1.0,
"offsets": [28, 32],
"isPixel": true,
"isPixel": false,
"data": {
"leftStatic": { "prefix": "staticLeft0" },
"leftPress": { "prefix": "pressedLeft0" },

View file

@ -0,0 +1,15 @@
{
"version": "2.0.0",
"songName": "Bopeebo",
"artist": "Kawai Sprite",
"timeFormat": "ms",
"timeChanges": [{ "t": 0, "bpm": 100, "n": 4, "d": 4, "bt": [4, 4, 4, 4] }],
"playData": {
"songVariations": [],
"difficulties": ["easy", "normal", "hard"],
"playableChars": { "bf": { "g": "gf", "o": "dad" } },
"stage": "mainStage",
"noteSkin": "Normal"
},
"generatedBy": "MasterEric (by hand)"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
{
"version": "2.0.0",
"songName": "Bopeebo",
"artist": "Kawai Sprite",
"timeFormat": "ms",
"timeChanges": [{ "t": 0, "bpm": 100, "n": 4, "d": 4, "bt": [4, 4, 4, 4] }],
"playData": {
"songVariations": [],
"difficulties": ["easy", "normal", "hard"],
"playableChars": { "bf": { "g": "gf", "o": "dad" } },
"stage": "mainStage",
"noteSkin": "Normal"
},
"generatedBy": "MasterEric (by hand)"
}

View file

@ -44,11 +44,12 @@
<!-- Assets -->
<assets path="assets/preload" rename="assets" exclude="*.ogg" if="web" />
<assets path="assets/preload" rename="assets" exclude="*.mp3" unless="web" />
<assets path="assets/shared" library="shared" exclude="*.fla|*.ogg" if="web" />
<assets path="assets/shared" library="shared" exclude="*.fla|*.mp3" unless="web" />
<library name="shared" preload="true" />
<!--
<assets path="assets/songs" library="songs" exclude="*.fla|*.ogg" if="web" />
<assets path="assets/songs" library="songs" exclude="*.fla|*.mp3" unless="web" />
<assets path="assets/shared" library="shared" exclude="*.fla|*.ogg" if="web" />
<assets path="assets/shared" library="shared" exclude="*.fla|*.mp3" unless="web" />
<assets path="assets/tutorial" library="tutorial" exclude="*.fla|*.ogg" if="web" />
<assets path="assets/tutorial" library="tutorial" exclude="*.fla|*.mp3" unless="web" />
<assets path="assets/week1" library="week1" exclude="*.fla|*.ogg" if="web" />
@ -68,7 +69,6 @@
<assets path="assets/weekend1" library="weekend1" exclude="*.fla|*.ogg" if="web" />
<assets path="assets/weekend1" library="weekend1" exclude="*.fla|*.mp3" unless="web" />
<library name="songs" preload="true" />
<library name="shared" preload="true" />
<library name="tutorial" preload="true" />
<library name="week1" preload="true" />
<library name="week2" preload="true" />
@ -87,8 +87,8 @@
<haxedef name="FLX_RECORD" />
<!-- Clean up the output -->
<haxedef name="no-traces" />
<!--
<haxedef name="echo-traces" />
-->
<haxedef name="ignore-inline" />
<haxeflag name="-w" value="-WDeprecated" />

View file

@ -127,4 +127,54 @@ class FunkinAssert
};
validateThrows(targetFunc, predicate, info);
}
static var capturedTraces:Array<String> = [];
public static function initAssertTrace():Void
{
var oldTrace = haxe.Log.trace;
haxe.Log.trace = function(v:Dynamic, ?infos:haxe.PosInfos) {
onTrace(v, infos);
// oldTrace(v, infos);
};
}
public static function clearTraces():Void
{
capturedTraces = [];
}
@:nullSafety(Off) // Why isn't haxe.std null-safe?
static function onTrace(v:Dynamic, ?infos:haxe.PosInfos)
{
// var str:String = haxe.Log.formatOutput(v, infos);
var str:String = Std.string(v);
capturedTraces.push(str);
#if (sys && echo_traces)
Sys.println('[TESTLOG] $str');
#end
}
/**
* Check the first string that was traced and validate it.
* @param expected
*/
public static inline function assertTrace(expected:String):Void
{
var actual:Null<String> = capturedTraces.shift();
Assert.isNotNull(actual);
Assert.areEqual(expected, actual);
}
/**
* Check the first string that was traced and validate it.
* @param expected
*/
public static inline function assertLastTrace(expected:String):Void
{
var actual:Null<String> = capturedTraces.pop();
Assert.isNotNull(actual);
Assert.areEqual(expected, actual);
}
}

View file

@ -30,8 +30,8 @@ class MockTest extends FunkinTest
{
// Test that mocking works.
var mockSprite = mockatoo.Mockatoo.mock(flixel.FlxSprite);
var mockAnim = mockatoo.Mockatoo.mock(flixel.animation.FlxAnimationController);
var mockSprite = Mockatoo.mock(flixel.FlxSprite);
var mockAnim = Mockatoo.mock(flixel.animation.FlxAnimationController);
mockSprite.animation = mockAnim;
var animData:funkin.data.animation.AnimationData =
@ -44,12 +44,12 @@ class MockTest extends FunkinTest
// Verify that the method was called once.
// If not, a VerificationException will be thrown and the test will fail.
mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1));
mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1));
FunkinAssert.validateThrows(function() {
// Attempt to verify the method was called.
// This should FAIL, since we didn't call the method.
mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1));
mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1));
}, function(err) {
return Std.isOfType(err, mockatoo.exception.VerificationException);
});

View file

@ -3,7 +3,7 @@ package funkin;
import flixel.FlxG;
import flixel.FlxState;
import funkin.Conductor;
import funkin.play.song.SongData.SongTimeChange;
import funkin.data.song.SongData.SongTimeChange;
import funkin.util.Constants;
import massive.munit.Assert;
@ -16,6 +16,8 @@ class ConductorTest extends FunkinTest
@Before
function before()
{
FunkinAssert.initAssertTrace();
resetGame();
// The ConductorState will advance the conductor when step() is called.
@ -193,16 +195,7 @@ class ConductorTest extends FunkinTest
function testSingleTimeChange():Void
{
// Start the song with a BPM of 120.
var songTimeChanges:Array<SongTimeChange> = [
{
t: 0,
b: 0,
bpm: 120,
n: 4,
d: 4,
bt: [4, 4, 4, 4]
}, // 120 bpm starting 0 sec/0 beats
];
var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120)];
Conductor.mapTimeChanges(songTimeChanges);
// All should be at 0.
@ -253,24 +246,7 @@ class ConductorTest extends FunkinTest
function testDoubleTimeChange():Void
{
// Start the song with a BPM of 120.
var songTimeChanges:Array<SongTimeChange> = [
{
t: 0,
b: 0,
bpm: 120,
n: 4,
d: 4,
bt: [4, 4, 4, 4]
}, // 120 bpm starting 0 sec/0 beats
{
t: 3000,
b: 6,
bpm: 90,
n: 4,
d: 4,
bt: [4, 4, 4, 4]
} // 90 bpm starting 3 sec/6 beats
];
var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120), new SongTimeChange(3000, 90)];
Conductor.mapTimeChanges(songTimeChanges);
// All should be at 0.
@ -354,30 +330,9 @@ class ConductorTest extends FunkinTest
{
// Start the song with a BPM of 120, then move to 90, then move to 180.
var songTimeChanges:Array<SongTimeChange> = [
{
t: 0,
b: null,
bpm: 120,
n: 4,
d: 4,
bt: [4, 4, 4, 4]
}, // 120 bpm starting 0 sec/0 beats
{
t: 3000,
b: null,
bpm: 90,
n: 4,
d: 4,
bt: [4, 4, 4, 4]
}, // 90 bpm starting 3 sec/6 beats
{
t: 6000,
b: null,
bpm: 180,
n: 4,
d: 4,
bt: [4, 4, 4, 4]
} // 90 bpm starting 3 sec/6 beats
new SongTimeChange(0, 120),
new SongTimeChange(3000, 90),
new SongTimeChange(6000, 180)
];
Conductor.mapTimeChanges(songTimeChanges);

View file

@ -17,7 +17,10 @@ class BaseRegistryTest extends FunkinTest
}
@BeforeClass
public function beforeClass() {}
public function beforeClass()
{
FunkinAssert.initAssertTrace();
}
@AfterClass
public function afterClass() {}
@ -118,7 +121,7 @@ class MyType implements IRegistryEntry<MyTypeData>
return 'MyType($id)';
}
public function _fetchData(id:String):Null<MyTypeData>
static function _fetchData(id:String):Null<MyTypeData>
{
return MyTypeRegistry.instance.parseEntryDataWithMigration(id, MyTypeRegistry.instance.fetchEntryVersion(id));
}
@ -153,17 +156,18 @@ class MyTypeRegistry extends BaseRegistry<MyType, MyTypeData>
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<MyTypeData>();
var jsonStr:String = loadEntryFile(id);
parser.fromJson(jsonStr);
switch (loadEntryFile(id))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
}
if (parser.errors.length > 0)
{
trace('[${registryId}] Failed to parse entry data: ${id}');
for (error in parser.errors)
{
trace(error);
}
printErrors(parser.errors, id);
return null;
}
return parser.value;
@ -177,31 +181,33 @@ class MyTypeRegistry extends BaseRegistry<MyType, MyTypeData>
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<MyTypeData_v0_1_x>();
var jsonStr:String = loadEntryFile(id);
parser.fromJson(jsonStr);
switch (loadEntryFile(id))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
}
if (parser.errors.length > 0)
{
trace('[${registryId}] Failed to parse entry data: ${id}');
for (error in parser.errors)
{
trace(error);
}
printErrors(parser.errors, id);
return null;
}
var oldData:MyTypeData_v0_1_x = parser.value;
return migrateData_v0_1_x(oldData);
}
var result:MyTypeData =
{
version: DATA_VERSION,
id: '${oldData.id}',
name: oldData.name,
data: []
};
return result;
function migrateData_v0_1_x(input:MyTypeData_v0_1_x):MyTypeData
{
return {
version: DATA_VERSION,
id: '${input.id}',
name: input.name,
data: []
};
}
public override function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null<MyTypeData>

View file

@ -123,9 +123,12 @@ class LevelRegistryTest extends FunkinTest
}
@Test
@Ignore("Requires redoing validation.")
public function testCreateEntryBlankPath():Void
{
// Using @:jcustomparse, `titleAsset` has a validation function that ensures it is not blank.
// This test makes sure that the validation function is being called, and that the error
// results in the level failing to parse.
FunkinAssert.validateThrows(function() {
var result:Null<Level> = LevelRegistry.instance.createEntry("blankpathtest");
}, function(err) {
@ -134,7 +137,6 @@ class LevelRegistryTest extends FunkinTest
}
@Test
@Ignore("Requires redoing validation.")
public function testFetchBadEntry():Void
{
var result:Null<Level> = LevelRegistry.instance.fetchEntry("blablabla");

View file

@ -86,7 +86,7 @@ class NoteStyleRegistryTest extends FunkinTest
@Test
public function testFetchDefault():Void
{
var nsrMock:NoteStyleRegistry = mock(NoteStyleRegistry);
var nsrMock = Mockatoo.mock(NoteStyleRegistry);
nsrMock.fetchDefault().callsRealMethod();

View file

@ -0,0 +1,133 @@
package funkin.data.song;
import funkin.data.song.SongData;
import funkin.data.song.SongRegistry;
import funkin.play.song.Song;
import massive.munit.Assert;
import massive.munit.async.AsyncFactory;
import massive.munit.util.Timer;
@:nullSafety
@:access(funkin.play.song.Song)
@:access(funkin.data.song.SongRegistry)
class SongRegistryTest extends FunkinTest
{
public function new()
{
super();
}
@BeforeClass
public function beforeClass():Void
{
FunkinAssert.initAssertTrace();
SongRegistry.instance.loadEntries();
}
@AfterClass
public function afterClass():Void {}
@Before
public function setup():Void {}
@After
public function tearDown():Void {}
@Test
public function testValid():Void
{
Assert.isNotNull(SongRegistry.instance);
}
@Test
public function testParseMetadata():Void
{
var result:Null<SongData.SongMetadata> = SongRegistry.instance.parseEntryMetadata("bopeebo");
Assert.isNotNull(result);
var expectedVersion:thx.semver.Version = "2.0.0";
Assert.areEqual(expectedVersion, result.version);
Assert.areEqual("Bopeebo", result.songName);
Assert.areEqual("Kawai Sprite", result.artist);
Assert.areEqual(SongData.SongTimeFormat.MILLISECONDS, result.timeFormat);
Assert.areEqual("MasterEric (by hand)", result.generatedBy);
}
@Test
public function testParseChartData():Void
{
var result:Null<SongData.SongChartData> = SongRegistry.instance.parseEntryChartData("bopeebo");
Assert.isNotNull(result);
var expectedVersion:thx.semver.Version = "2.0.0";
Assert.areEqual(expectedVersion, result.version);
}
/**
* A test validating an error is thrown when attempting to parse chart data as metadata.
*/
@Test
public function testParseMetadataSwapped():Void
{
// Arrange
FunkinAssert.clearTraces();
// Act
var result:Null<SongData.SongMetadata> = SongRegistry.instance.parseEntryMetadata("bopeebo-swapped");
// Assert
Assert.isNull(result);
FunkinAssert.assertTrace("[SONG] Failed to parse entry data: bopeebo-swapped");
FunkinAssert.assertTrace(" Unknown variable \"scrollSpeed\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:3");
FunkinAssert.assertTrace(" Unknown variable \"events\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:7");
FunkinAssert.assertTrace(" Unknown variable \"notes\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:185");
FunkinAssert.assertTrace(" Uninitialized variable \"songName\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
FunkinAssert.assertTrace(" Uninitialized variable \"timeFormat\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
FunkinAssert.assertTrace(" Uninitialized variable \"timeChanges\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
FunkinAssert.assertTrace(" Uninitialized variable \"playData\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
FunkinAssert.assertTrace(" Uninitialized variable \"artist\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
}
/**
* A test validating an error is thrown when attempting to parse metadata as chart data.
*/
@Test
public function testParseChartDataSwapped():Void
{
// Arrange
FunkinAssert.clearTraces();
// Act
var result:Null<SongData.SongChartData> = SongRegistry.instance.parseEntryChartData("bopeebo-swapped");
// Assert
Assert.isNull(result);
FunkinAssert.assertTrace("[SONG] Failed to parse entry data: bopeebo-swapped");
FunkinAssert.assertTrace(" Unknown variable \"songName\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:3");
FunkinAssert.assertTrace(" Unknown variable \"artist\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:4");
FunkinAssert.assertTrace(" Unknown variable \"timeFormat\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:5");
FunkinAssert.assertTrace(" Unknown variable \"timeChanges\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:6");
FunkinAssert.assertTrace(" Unknown variable \"playData\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:7");
FunkinAssert.assertTrace(" Uninitialized variable \"notes\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15");
FunkinAssert.assertTrace(" Uninitialized variable \"events\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15");
FunkinAssert.assertTrace(" Uninitialized variable \"scrollSpeed\"");
FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15");
}
}

View file

@ -1,11 +1,18 @@
package funkin.play.notes.notestyle;
import flixel.util.FlxSort;
import funkin.util.SortUtil;
import flixel.graphics.FlxGraphic;
import flixel.graphics.frames.FlxFrame;
import flixel.graphics.frames.FlxFramesCollection;
import massive.munit.util.Timer;
import massive.munit.Assert;
import massive.munit.async.AsyncFactory;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.play.notes.notestyle.NoteStyle;
import flixel.animation.FlxAnimationController;
import openfl.utils.Assets;
import flixel.math.FlxPoint;
@:access(funkin.play.notes.notestyle.NoteStyle)
class NoteStyleTest extends FunkinTest
@ -31,20 +38,142 @@ class NoteStyleTest extends FunkinTest
public function tearDown() {}
@Test
@Ignore("This test doesn't work, crashes when the project has 2 mocks of the same class???")
public function testBuildNoteSprite()
{
var target:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry("funkin");
Assert.isNotNull(target);
var mockNoteSprite:NoteSprite = mock(NoteSprite);
// var mockAnim = mock(FlxAnimationController);
// mockNoteSprite.animation = mockAnim;
// Arrange
var mockNoteSprite = Mockatoo.mock(NoteSprite);
var mockAnim = Mockatoo.mock(FlxAnimationController);
var scale = new FlxPoint(1, 1); // handle sprite.scale.x on the mock
mockNoteSprite.animation = mockAnim; // Tell the mock to forward calls to the animation controller mock.
mockNoteSprite.scale.returns(scale); // Redirect this final variable to a local variable.
mockNoteSprite.antialiasing.callsRealMethod(); // Tell the mock to treat this like a normal property.
mockNoteSprite.frames.callsRealMethod(); // Tell the mock to treat this like a normal property.
// Act
target.buildNoteSprite(mockNoteSprite);
Assert.areEqual(mockNoteSprite.frames, []);
var expectedGraphic:FlxGraphic = FlxG.bitmap.add("shared:assets/shared/images/arrows.png");
// Assert
Assert.isNotNull(mockNoteSprite.frames);
mockNoteSprite.frames.frames.sort(SortUtil.byFrameName);
var frameCount:Int = mockNoteSprite.frames.frames.length;
Assert.areEqual(24, frameCount);
// Validate each frame.
for (i in 0...frameCount)
{
var currentFrame:FlxFrame = mockNoteSprite.frames.frames[i];
switch (i)
{
case 0:
Assert.areEqual("confirmDown0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 1:
Assert.areEqual("confirmDown0002", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 2:
Assert.areEqual("confirmLeft0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 3:
Assert.areEqual("confirmLeft0002", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 4:
Assert.areEqual("confirmRight0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 5:
Assert.areEqual("confirmRight0002", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 6:
Assert.areEqual("confirmUp0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 7:
Assert.areEqual("confirmUp0002", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 8:
Assert.areEqual("noteDown0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 9:
Assert.areEqual("noteLeft0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 10:
Assert.areEqual("noteRight0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 11:
Assert.areEqual("noteUp0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 12:
Assert.areEqual("pressedDown0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 13:
Assert.areEqual("pressedDown0002", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 14:
Assert.areEqual("pressedLeft0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 15:
Assert.areEqual("pressedLeft0002", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 16:
Assert.areEqual("pressedRight0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 17:
Assert.areEqual("pressedRight0002", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 18:
Assert.areEqual("pressedUp0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 19:
Assert.areEqual("pressedUp0002", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 20:
Assert.areEqual("staticDown0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 21:
Assert.areEqual("staticLeft0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 22:
Assert.areEqual("staticRight0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
case 23:
Assert.areEqual("staticUp0001", currentFrame.name);
Assert.areEqual(expectedGraphic, currentFrame.parent);
default:
Assert.fail('Got unexpected frame number ${i}');
}
}
// Verify animations were applied.
@:privateAccess {
mockAnim.addByPrefix('purpleScroll', 'noteLeft', 24, false, false, false).verify(times(1));
mockAnim.addByPrefix('blueScroll', 'noteDown', 24, false, false, false).verify(times(1));
mockAnim.addByPrefix('greenScroll', 'noteUp', 24, false, false, false).verify(times(1));
mockAnim.addByPrefix('redScroll', 'noteRight', 24, false, false, false).verify(times(1));
mockAnim.destroyAnimations().verify(times(1));
mockAnim.set_frameIndex(0).verify(times(1));
// Verify there were no other functions called.
mockAnim.verifyZeroInteractions();
}
// Verify sprite was initialized.
@:privateAccess {
mockNoteSprite.set_graphic(expectedGraphic).verify(times(1));
mockNoteSprite.graphicLoaded().verify(times(1));
mockNoteSprite.set_antialiasing(true).verify(times(1));
mockNoteSprite.set_frames(mockNoteSprite.frames).verify(times(1));
mockNoteSprite.set_frame(mockNoteSprite.frames.frames[21]).verify(times(1));
mockNoteSprite.resetHelpers().verify(times(1));
Assert.areEqual(1, mockNoteSprite.scale.x);
Assert.areEqual(1, mockNoteSprite.scale.y);
// Verify there were no other functions called.
mockNoteSprite.verifyZeroInteractions();
}
}
@Test

View file

@ -3,7 +3,7 @@ package funkin.util;
import flixel.FlxObject;
import flixel.FlxSprite;
import flixel.util.FlxSort;
import funkin.play.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
import massive.munit.util.Timer;
import massive.munit.Assert;
import massive.munit.async.AsyncFactory;

View file

@ -34,8 +34,8 @@ class FlxAnimationUtilTest extends FunkinTest
public function testAddAtlasAnimation()
{
// Build a mock child class of FlxSprite
var mockSprite = mock(FlxSprite);
var mockAnim = mock(FlxAnimationController);
var mockSprite = Mockatoo.mock(FlxSprite);
var mockAnim = Mockatoo.mock(FlxAnimationController);
mockSprite.animation = mockAnim;
var animData:AnimationData =
@ -85,8 +85,8 @@ class FlxAnimationUtilTest extends FunkinTest
public function testAddAtlasAnimations()
{
// Build a mock child class of FlxSprite
var mockSprite = mock(FlxSprite);
var mockAnim = mock(FlxAnimationController);
var mockSprite = Mockatoo.mock(FlxSprite);
var mockAnim = Mockatoo.mock(FlxAnimationController);
mockSprite.animation = mockAnim;
var animData:Array<AnimationData> = [