package funkin.play.song; import flixel.util.typeLimit.OneOfTwo; import funkin.play.song.ScriptedSong; import funkin.util.assets.DataAssets; import haxe.DynamicAccess; import haxe.Json; import openfl.utils.Assets; import thx.semver.Version; using StringTools; /** * Contains utilities for loading and parsing stage data. */ class SongDataParser { /** * A list containing all the songs available to the game. */ static final songCache:Map = new Map(); static final DEFAULT_SONG_ID = 'UNKNOWN'; static final SONG_DATA_PATH = 'songs/'; static final SONG_DATA_SUFFIX = '/metadata.json'; /** * Parses and preloads the game's song metadata and scripts when the game starts. * * If you want to force song metadata to be reloaded, you can just call this function again. */ public static function loadSongCache():Void { clearSongCache(); trace("[SONGDATA] Loading song cache..."); // // SCRIPTED SONGS // var scriptedSongClassNames:Array = ScriptedSong.listScriptClasses(); trace(' Instantiating ${scriptedSongClassNames.length} scripted songs...'); for (songCls in scriptedSongClassNames) { var song:Song = ScriptedSong.init(songCls, DEFAULT_SONG_ID); if (song != null) { trace(' Loaded scripted song: ${song.songId}'); songCache.set(song.songId, song); } else { trace(' Failed to instantiate scripted song class: ${songCls}'); } } // // UNSCRIPTED SONGS // var songIdList:Array = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX); var unscriptedSongIds:Array = songIdList.filter(function(songId:String):Bool { return !songCache.exists(songId); }); trace(' Instantiating ${unscriptedSongIds.length} non-scripted songs...'); for (songId in unscriptedSongIds) { try { var song = new Song(songId); if (song != null) { trace(' Loaded song data: ${song.songId}'); songCache.set(song.songId, song); } } catch (e) { trace(' An error occurred while loading song data: ${songId}'); trace(e); // Assume error was already logged. continue; } } trace(' Successfully loaded ${Lambda.count(songCache)} stages.'); } /** * Retrieves a particular song from the cache. */ public static function fetchSong(songId:String):Null { if (songCache.exists(songId)) { var song:Song = songCache.get(songId); trace('[STAGEDATA] Successfully fetch song: ${songId}'); return song; } else { trace('[STAGEDATA] Failed to fetch song, not found in cache: ${songId}'); return null; } } static function clearSongCache():Void { if (songCache != null) { songCache.clear(); } } public static function parseSongMetadata(songId:String):Array { var result:Array = []; var rawJson:String = loadSongMetadataFile(songId); var jsonData:Dynamic = null; try { jsonData = Json.parse(rawJson); } catch (e) { } var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId); songMetadata = SongValidator.validateSongMetadata(songMetadata, songId); if (songMetadata == null) { return result; } result.push(songMetadata); var variations = songMetadata.playData.songVariations; for (variation in variations) { var variationRawJson:String = loadSongMetadataFile(songId, variation); var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationRawJson, '${songId}_${variation}'); variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}_${variation}'); if (variationSongMetadata != null) { variationSongMetadata.variation = variation; result.push(variationSongMetadata); } } return result; } static function loadSongMetadataFile(songPath:String, variation:String = ''):String { var songMetadataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/metadata-$variation') : Paths.json('$SONG_DATA_PATH$songPath/metadata'); var rawJson:String = Assets.getText(songMetadataFilePath).trim(); while (!rawJson.endsWith("}")) { rawJson = rawJson.substr(0, rawJson.length - 1); } return rawJson; } public static function parseSongChartData(songId:String, variation:String = ""):SongChartData { var rawJson:String = loadSongChartDataFile(songId, variation); var jsonData:Dynamic = null; try { jsonData = Json.parse(rawJson); } catch (e) { } var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId); songChartData = SongValidator.validateSongChartData(songChartData, songId); if (songChartData == null) { trace('Failed to validate song chart data: ${songId}'); return null; } return songChartData; } static function loadSongChartDataFile(songPath:String, variation:String = ''):String { var songChartDataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/chart'); var rawJson:String = Assets.getText(songChartDataFilePath).trim(); while (!rawJson.endsWith("}")) { rawJson = rawJson.substr(0, rawJson.length - 1); } return rawJson; } } typedef SongMetadata = { /** * A semantic versioning string for the song data format. * */ var version:Version; var songName:String; var artist:String; var timeFormat:SongTimeFormat; var divisions:Int; var timeChanges:Array; var loop:Bool; var playData:SongPlayData; var generatedBy:String; /** * Defaults to ''. Populated later. */ var variation:String; }; typedef SongPlayData = { var songVariations:Array; var difficulties:Array; /** * Keys are the player characters and the values give info on what opponent/GF/inst to use. */ var playableChars:DynamicAccess; var stage:String; var noteSkin:String; } typedef RawSongPlayableChar = { var g:String; var o:String; var i:String; } typedef RawSongNoteData = { /** * The timestamp of the note. The timestamp is in the format of the song's time format. */ var t:Float; /** * Data for the note. Represents the index on the strumline. * 0 = left, 1 = down, 2 = up, 3 = right * `floor(direction / strumlineSize)` specifies which strumline the note is on. * 0 = player, 1 = opponent, etc. */ var d:Int; /** * Length of the note, if applicable. * Defaults to 0 for single notes. */ var l:Float; /** * The kind of the note. * This can allow the note to include information used for custom behavior. * Defaults to blank or `"normal"`. */ var k:String; } abstract SongNoteData(RawSongNoteData) { public function new(time:Float, data:Int, length:Float = 0, kind:String = "") { this = { t: time, d: data, l: length, k: kind }; } public var time(get, set):Float; public function get_time():Float { return this.t; } public function set_time(value:Float):Float { return this.t = value; } /** * The raw data for the note. */ public var data(get, set):Int; public function get_data():Int { return this.d; } public function set_data(value:Int):Int { return this.d = value; } /** * The direction of the note, if applicable. * Strips the strumline index from the data. * * 0 = left, 1 = down, 2 = up, 3 = right */ public inline function getDirection(strumlineSize:Int = 4):Int { return this.d % strumlineSize; } /** * 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.d / strumlineSize); } public inline function getMustHitNote(strumlineSize:Int = 4):Bool { return getStrumlineIndex(strumlineSize) == 0; } public var length(get, set):Float; public function get_length():Float { return this.l; } public function set_length(value:Float):Float { return this.l = value; } public var kind(get, set):String; public function get_kind():String { if (this.k == null || this.k == '') return 'normal'; return this.k; } public function set_kind(value:String):String { if (value == 'normal' || value == '') value = null; return this.k = value; } } typedef RawSongEventData = { /** * The timestamp of the event. The timestamp is in the format of the song's time format. */ var t:Float; /** * The kind of the event. * Examples include "FocusCamera" and "PlayAnimation" * Custom events can be added by scripts with the `ScriptedSongEvent` class. */ var e:String; /** * The data for the event. * This can allow the event to include information used for custom behavior. * Data type depends on the event kind. It can be anything that's JSON serializable. */ var v:Dynamic; } abstract SongEventData(RawSongEventData) { public function new(time:Float, event:String, value:Dynamic = null) { this = { t: time, e: event, v: value }; } public var time(get, set):Float; public function get_time():Float { return this.t; } public function set_time(value:Float):Float { return this.t = value; } public var event(get, set):String; public function get_event():String { return this.e; } public function set_event(value:String):String { return this.e = value; } public var value(get, set):Dynamic; public function get_value():Dynamic { return this.v; } public function set_value(value:Dynamic):Dynamic { return this.v = value; } public inline function getBool():Bool { return cast this.v; } public inline function getInt():Int { return cast this.v; } public inline function getFloat():Float { return cast this.v; } public inline function getString():String { return cast this.v; } public inline function getArray():Array { return cast this.v; } public inline function getMap():DynamicAccess { return cast this.v; } public inline function getBoolArray():Array { return cast this.v; } } abstract SongPlayableChar(RawSongPlayableChar) { public function new(girlfriend:String, opponent:String, inst:String = "") { this = { g: girlfriend, o: opponent, i: inst }; } public var girlfriend(get, set):String; public function get_girlfriend():String { return this.g; } public function set_girlfriend(value:String):String { return this.g = value; } public var opponent(get, set):String; public function get_opponent():String { return this.o; } public function set_opponent(value:String):String { return this.o = value; } public var inst(get, set):String; public function get_inst():String { return this.i; } public function set_inst(value:String):String { return this.i = value; } } typedef RawSongChartData = { var version:Version; var scrollSpeed:DynamicAccess; var events:Array; var notes:DynamicAccess>; var generatedBy:String; }; @:forward abstract SongChartData(RawSongChartData) { public function new(scrollSpeed:DynamicAccess, events:Array, notes:DynamicAccess>) { this = { version: SongMigrator.CHART_VERSION, events: events, notes: notes, scrollSpeed: scrollSpeed, generatedBy: SongValidator.DEFAULT_GENERATEDBY } } public function getScrollSpeed(diff:String = 'default'):Float { var result:Float = this.scrollSpeed.get(diff); if (result == 0.0 && diff != 'default') return getScrollSpeed('default'); return (result == 0.0) ? 1.0 : result; } } typedef RawSongTimeChange = { /** * Timestamp in specified `timeFormat`. */ var t:Float; /** * Time in beats (int). The game will calculate further beat values based on this one, * so it can do it in a simple linear fashion. */ var b:Int; /** * Quarter notes per minute (float). Cannot be empty in the first element of the list, * but otherwise it's optional, and defaults to the value of the previous element. */ var bpm:Float; /** * Time signature numerator (int). Optional, defaults to 4. */ var n:Int; /** * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. */ var d:Int; /** * Beat tuplets (Array or int). This defines how many steps each beat is divided into. * It can either be an array of length `n` (see above) or a single integer number. * Optional, defaults to `[4]`. */ var bt:OneOfTwo>; } typedef RawConductorTimeChange = { > RawSongTimeChange, /** * The time in the song (in steps) that this change occurs at. * This time is somewhat weird because the rate it increases is dependent on the BPM at that point in the song. */ public var st:Float; } /** * Add aliases to the minimalized property names of the typedef, * to improve readability. */ abstract SongTimeChange(RawSongTimeChange) { public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array) { this = { t: timeStamp, b: beatTime, bpm: bpm, n: timeSignatureNum, d: timeSignatureDen, bt: beatTuplets, } } public var timeStamp(get, set):Float; public function get_timeStamp():Float { return this.t; } public function set_timeStamp(value:Float):Float { return this.t = value; } public var beatTime(get, set):Int; public function get_beatTime():Int { return this.b; } public function set_beatTime(value:Int):Int { return this.b = value; } public var bpm(get, set):Float; public function get_bpm():Float { return this.bpm; } public function set_bpm(value:Float):Float { return this.bpm = value; } public var timeSignatureNum(get, set):Int; public function get_timeSignatureNum():Int { return this.n; } public function set_timeSignatureNum(value:Int):Int { return this.n = value; } public var timeSignatureDen(get, set):Int; public function get_timeSignatureDen():Int { return this.d; } public function set_timeSignatureDen(value:Int):Int { return this.d = value; } public var beatTuplets(get, set):Array; public function get_beatTuplets():Array { if (Std.isOfType(this.bt, Int)) { return [this.bt]; } else { return this.bt; } } public function set_beatTuplets(value:Array):Array { return this.bt = value; } } abstract ConductorTimeChange(RawConductorTimeChange) { public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array) { this = { t: timeStamp, b: beatTime, bpm: bpm, n: timeSignatureNum, d: timeSignatureDen, bt: beatTuplets, st: 0.0 } } public var timeStamp(get, set):Float; public function get_timeStamp():Float { return this.t; } public function set_timeStamp(value:Float):Float { return this.t = value; } public var beatTime(get, set):Int; public function get_beatTime():Int { return this.b; } public function set_beatTime(value:Int):Int { return this.b = value; } public var bpm(get, set):Float; public function get_bpm():Float { return this.bpm; } public function set_bpm(value:Float):Float { return this.bpm = value; } public var timeSignatureNum(get, set):Int; public function get_timeSignatureNum():Int { return this.n; } public function set_timeSignatureNum(value:Int):Int { return this.n = value; } public var timeSignatureDen(get, set):Int; public function get_timeSignatureDen():Int { return this.d; } public function set_timeSignatureDen(value:Int):Int { return this.d = value; } public var beatTuplets(get, set):Array; public function get_beatTuplets():Array { if (Std.isOfType(this.bt, Int)) { return [this.bt]; } else { return this.bt; } } public function set_beatTuplets(value:Array):Array { return this.bt = value; } public var stepTime(get, set):Float; public function get_stepTime():Float { return this.st; } public function set_stepTime(value:Float):Float { return this.st = value; } } enum abstract SongTimeFormat(String) from String to String { var TICKS = "ticks"; var FLOAT = "float"; var MILLISECONDS = "ms"; }