1
0
Fork 0
mirror of https://github.com/ninjamuffin99/Funkin.git synced 2024-11-25 00:06:40 +00:00

WIP on song data loading

This commit is contained in:
Eric Myllyoja 2022-09-13 01:09:30 -04:00
parent eb3ad49a30
commit 194d8e6ce6
9 changed files with 621 additions and 43 deletions

View file

@ -9,9 +9,9 @@ import flixel.math.FlxRect;
import flixel.util.FlxColor;
import funkin.charting.ChartingState;
import funkin.modding.module.ModuleHandler;
import funkin.play.PicoFight;
import funkin.play.PlayState;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.stage.StageData;
import funkin.ui.PreferencesMenu;
import funkin.ui.animDebugShit.DebugBoundingState;
@ -28,11 +28,6 @@ import io.colyseus.Room;
#if discord_rpc
import Discord.DiscordClient;
#end
#if desktop
import sys.FileSystem;
import sys.io.File;
import sys.thread.Thread;
#end
/**
* Initializes the game state using custom defines.
@ -123,6 +118,7 @@ class InitState extends FlxTransitionableState
// FlxTransitionableState.skipNextTransOut = true;
FlxTransitionableState.skipNextTransIn = true;
SongDataParser.loadSongCache();
StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache();
ModuleHandler.buildModuleCallbacks();

View file

@ -2,6 +2,7 @@ package funkin.modding;
import funkin.modding.module.ModuleHandler;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.song.SongData;
import funkin.play.stage.StageData;
import polymod.Polymod;
import polymod.backends.PolymodAssets.PolymodAssetType;
@ -231,6 +232,7 @@ class PolymodHandler
// Reload everything that is cached.
// Currently this freezes the game for a second but I guess that's tolerable?
SongDataParser.loadSongCache();
StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache();
ModuleHandler.loadModuleCache();

View file

@ -1,5 +1,6 @@
package funkin.play.song;
import funkin.play.song.Song;
import polymod.hscript.HScriptedClass;
@:hscriptClass

View file

@ -2,6 +2,8 @@ package funkin.play.song;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.song.SongData.SongMetadata;
import funkin.play.song.SongData.SongTimeChange;
import funkin.play.song.SongData.SongTimeFormat;
/**
* This is a data structure managing information about the current song.
@ -14,30 +16,85 @@ import funkin.play.song.SongData.SongMetadata;
*/
class Song // implements IPlayStateScriptedClass
{
public var songId(default, null):String;
public final songId:String;
public var songName(get, null):String;
final _metadata:Array<SongMetadata>;
final _metadata:SongMetadata;
// final _chartData:SongChartData;
final difficulties:Map<String, SongDifficulty>;
public function new(id:String)
{
this.songId = id;
difficulties = new Map<String, SongDifficulty>();
_metadata = SongDataParser.parseSongMetadata(songId);
if (_metadata == null)
if (_metadata == null || _metadata.length == 0)
{
throw 'Could not find song data for songId: $songId';
}
populateFromMetadata();
}
function get_songName():String
function populateFromMetadata()
{
if (_metadata == null)
return null;
return _metadata.name;
// Variations may have different artist, time format, generatedBy, etc.
for (metadata in _metadata)
{
for (diffId in metadata.playData.difficulties)
{
var difficulty = new SongDifficulty(diffId, metadata.variation);
difficulty.songName = metadata.songName;
difficulty.songArtist = metadata.artist;
difficulty.timeFormat = metadata.timeFormat;
difficulty.divisions = metadata.divisions;
difficulty.timeChanges = metadata.timeChanges;
difficulty.loop = metadata.loop;
difficulty.generatedBy = metadata.generatedBy;
difficulties.set(diffId, difficulty);
}
}
}
/**
* Parse and cache the chart for a specific difficulty.
*/
public function cacheChart(diffId:String)
{
getDifficulty(diffId).cacheChart();
}
/**
* Parse and cache the chart for all difficulties of this song.
*/
public function cacheCharts()
{
for (difficulty in difficulties)
{
difficulty.cacheChart();
}
}
/**
* Retrieve the metadata for a specific difficulty, including the chart if it is loaded.
*/
public function getDifficulty(diffId:String):SongDifficulty
{
return difficulties.get(diffId);
}
/**
* Purge the cached chart data for each difficulty of this song.
*/
public function clearCharts()
{
for (diff in difficulties)
{
diff.clearChart();
}
}
public function toString():String
@ -45,3 +102,45 @@ class Song // implements IPlayStateScriptedClass
return 'Song($songId)';
}
}
class SongDifficulty
{
/**
* The difficulty ID, such as `easy` or `hard`.
*/
public final difficulty:String;
/**
* The metadata file that contains this difficulty.
*/
public final variation:String;
public var songName:String = SongValidator.DEFAULT_SONGNAME;
public var songArtist:String = SongValidator.DEFAULT_ARTIST;
public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT;
public var divisions:Int = SongValidator.DEFAULT_DIVISIONS;
public var loop:Bool = SongValidator.DEFAULT_LOOP;
public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY;
public var timeChanges:Array<SongTimeChange> = [];
public var scrollSpeed(default, null):Float = SongValidator.DEFAULT_SCROLLSPEED;
// public var notes(default, null):Array<;
public function new(diffId:String, variation:String)
{
this.difficulty = diffId;
this.variation = variation;
}
public function cacheChart():Void
{
// TODO: Parse chart data
}
public function clearChart():Void
{
// notes = null;
}
}

View file

@ -1,6 +1,10 @@
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;
@ -11,19 +15,14 @@ using StringTools;
*/
class SongDataParser
{
/**
* The current version string for the stage data format.
* Handle breaking changes by incrementing this value
* and adding migration to the SongMigrator class.
*/
public static final CHART_VERSION:String = "2.0.0";
/**
* A list containing all the songs available to the game.
*/
static final songCache:Map<String, Song> = new Map<String, Song>();
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.
@ -57,7 +56,7 @@ class SongDataParser
//
// UNSCRIPTED SONGS
//
var songIdList:Array<String> = DataAssets.listDataFilesInPath('songs/');
var songIdList:Array<String> = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX);
var unscriptedSongIds:Array<String> = songIdList.filter(function(songId:String):Bool
{
return !songCache.exists(songId);
@ -77,6 +76,7 @@ class SongDataParser
catch (e)
{
trace(' An error occurred while loading song data: ${songId}');
trace(e);
// Assume error was already logged.
continue;
}
@ -111,14 +111,50 @@ class SongDataParser
}
}
public static function parseSongMetadata(songId:String):Null<SongMetadata>
public static function parseSongMetadata(songId:String):Array<SongMetadata>
{
return null;
var result:Array<SongMetadata> = [];
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, variant:String = ''):String
static function loadSongMetadataFile(songPath:String, variation:String = ''):String
{
var songMetadataFilePath:String = (variant != '') ? Paths.json('songs/${songPath}') : Paths.json('songs/${songPath}');
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();
@ -129,10 +165,52 @@ class SongDataParser
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;
@ -140,12 +218,224 @@ typedef SongMetadata =
var timeFormat:SongTimeFormat;
var divisions:Int;
var timeChanges:Array<SongTimeChange>;
var loop:Bool;
var playData:SongPlayData;
var generatedBy:String;
/**
* Defaults to ''. Populated later.
*/
var variation:String;
};
typedef SongPlayData =
{
var songVariations:Array<String>;
var difficulties:Array<String>;
/**
* Keys are the player characters and the values give info on what opponent/GF/inst to use.
*/
var playableChars:DynamicAccess<SongPlayableChar>;
var stage:String;
var noteSkin:String;
}
typedef RawSongPlayableChar =
{
var g:String;
var o:String;
var i:String;
}
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 SongChartData =
{
};
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<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]`.
*/
var bt:OneOfTwo<Int, Array<Int>>;
}
/**
* 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<Int>)
{
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<Int>;
public function get_beatTuplets():Array<Int>
{
if (Std.isOfType(this.bt, Int))
{
return [this.bt];
}
else
{
return this.bt;
}
}
public function set_beatTuplets(value:Array<Int>):Array<Int>
{
return this.bt = value;
}
}
enum abstract SongTimeFormat(String) from String to String
{
var TICKS = "ticks";

View file

@ -0,0 +1,79 @@
package funkin.play.song;
import funkin.play.song.SongData.SongChartData;
import funkin.play.song.SongData.SongMetadata;
import funkin.util.VersionUtil;
class SongMigrator
{
/**
* The current latest version string for the song data format.
* Handle breaking changes by incrementing this value
* and adding migration to the SongMigrator class.
*/
public static final CHART_VERSION:String = "2.0.0";
public static final CHART_VERSION_RULE:String = "2.0.x";
public static function migrateSongMetadata(jsonData:Dynamic, songId:String):SongMetadata
{
if (jsonData.version)
{
if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
{
trace('[SONGDATA] Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.');
var songMetadata:SongMetadata = cast jsonData;
return songMetadata;
}
else
{
trace('[SONGDATA] Song (${songId}) metadata version (${jsonData.version}) is outdated.');
switch (jsonData.version)
{
// TODO: Add migration functions as cases here.
default:
// Unknown version.
trace('[SONGDATA] Song (${songId}) unknown metadata version: ${jsonData.version}');
}
}
}
else
{
trace('[SONGDATA] Song metadata version is missing.');
}
return null;
}
public static function migrateSongChartData(jsonData:Dynamic, songId:String):SongChartData
{
if (jsonData.version)
{
if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
{
trace('[SONGDATA] Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.');
var songMetadata:SongMetadata = cast jsonData;
return songMetadata;
}
else
{
trace('[SONGDATA] Song (${songId}) chart version (${jsonData.version}) is outdated.');
switch (jsonData.version)
{
// TODO: Add migration functions as cases here.
default:
// Unknown version.
trace('[SONGDATA] Song (${songId}) unknown chart version: ${jsonData.version}');
}
}
}
else
{
trace('[SONGDATA] Song chart version is missing.');
}
return null;
}
}

View file

@ -1,12 +0,0 @@
package funkin.play.song;
class SongMigrator
{
public static function migrateSongMetadata(song:Song, jsonData:Dynamic)
{
}
public static function migrateSongChart(song:Song, jsonData:Dynamic)
{
}
}

View file

@ -0,0 +1,123 @@
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;
/**
* For SongMetadata and SongChartData objects,
* ensures mandatory fields are present and populates optional fields with default values.
*/
class SongValidator
{
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:Int = -1;
public static final DEFAULT_LOOP:Bool = false;
public static final DEFAULT_GENERATEDBY:String = "Unknown";
public static final DEFAULT_SCROLLSPEED:Float = 1.0;
/**
* Validates the fields of a SongMetadata object (excluding the version field).
*
* @param input The SongMetadata object to validate.
* @param songId The ID of the song being validated. Only used for error messages.
* @return The validated SongMetadata object.
*/
public static function validateSongMetadata(input:SongMetadata, songId:String = 'unknown'):SongMetadata
{
if (input == null)
{
trace('[SONGDATA] Could not parse metadata for song ${songId}');
return null;
}
if (input.songName == null)
{
trace('[SONGDATA] Song ${songId} is missing a songName field. ');
input.songName = DEFAULT_SONGNAME;
}
if (input.artist == null)
{
trace('[SONGDATA] Song ${songId} is missing an artist field. ');
input.artist = DEFAULT_ARTIST;
}
if (input.timeFormat == null)
{
trace('[SONGDATA] Song ${songId} is missing a timeFormat field. ');
input.timeFormat = DEFAULT_TIMEFORMAT;
}
if (input.generatedBy == null)
{
input.generatedBy = DEFAULT_GENERATEDBY;
}
input.timeChanges = validateTimeChanges(input.timeChanges, songId);
input.playData = validatePlayData(input.playData, songId);
input.variation = '';
return input;
}
/**
* Validates the fields of a SongPlayData object.
*
* @param input The SongPlayData object to validate.
* @param songId The ID of the song being validated. Only used for error messages.
* @return The validated SongPlayData object.
*/
public static function validatePlayData(input:SongPlayData, songId:String = 'unknown'):SongPlayData
{
return input;
}
/**
* Validates the fields of a TimeChange object.
*
* @param input The TimeChange object to validate.
* @param songId The ID of the song being validated. Only used for error messages.
* @return The validated TimeChange object.
*/
public static function validateTimeChange(input:SongTimeChange, songId:String = 'unknown'):SongTimeChange
{
return input;
}
/**
* Validates multiple TimeChange objects in an array.
*/
public static function validateTimeChanges(input:Array<SongTimeChange>, songId:String = 'unknown'):Array<SongTimeChange>
{
if (input == null)
{
trace('[SONGDATA] Song ${songId} is missing a timeChanges field. ');
return [];
}
input = input.map((timeChange) -> validateTimeChange(timeChange, songId));
return input;
}
/**
* Validates the fields of a SongChartData object (excluding the version field).
*
* @param input The SongChartData object to validate.
* @param songId The ID of the song being validated. Only used for error messages.
* @return The validated SongChartData object.
*/
public static function validateSongChartData(input:SongChartData, songId:String = 'unknown'):SongChartData
{
if (input == null)
{
trace('[SONGDATA] Could not parse chart data for song ${songId}');
return null;
}
return input;
}
}

View file

@ -9,7 +9,7 @@ class DataAssets
return 'assets/data/${path}';
}
public static function listDataFilesInPath(path:String, ?ext:String = '.json'):Array<String>
public static function listDataFilesInPath(path:String, ?suffix:String = '.json'):Array<String>
{
var textAssets = openfl.utils.Assets.list();
var queryPath = buildDataPath(path);
@ -17,9 +17,9 @@ class DataAssets
var results:Array<String> = [];
for (textPath in textAssets)
{
if (textPath.startsWith(queryPath) && textPath.endsWith(ext))
if (textPath.startsWith(queryPath) && textPath.endsWith(suffix))
{
var pathNoSuffix = textPath.substring(0, textPath.length - ext.length);
var pathNoSuffix = textPath.substring(0, textPath.length - suffix.length);
var pathNoPrefix = pathNoSuffix.substring(queryPath.length);
// No duplicates! Why does this happen?