package funkin.data; import funkin.util.assets.DataAssets; import funkin.util.VersionUtil; import haxe.Constraints.Constructible; /** * The entry's constructor function must take a single argument, the entry's ID. */ typedef EntryConstructorFunction = String->Void; /** * A base type for a Registry, which is an object which handles loading scriptable objects. * * @param T The type to construct. Must implement `IRegistryEntry`. * @param J The type of the JSON data used when constructing. */ @:generic abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructorFunction>), J> { /** * The ID of the registry. Used when logging. */ public final registryId:String; final dataFilePath:String; /** * A map of entry IDs to entries. */ final entries:Map<String, T>; /** * A map of entry IDs to scripted class names. */ final scriptedEntryIds:Map<String, String>; /** * The version rule to use when loading entries. * If the entry's version does not match this rule, migration is needed. */ final versionRule:thx.semver.VersionRule; // public abstract static final instance:BaseRegistry<T, J> = new BaseRegistry<>(); /** * @param registryId A readable ID for this registry, used when logging. * @param dataFilePath The path (relative to `assets/data`) to search for JSON files. */ public function new(registryId:String, dataFilePath:String, ?versionRule:thx.semver.VersionRule) { this.registryId = registryId; this.dataFilePath = dataFilePath; this.versionRule = versionRule == null ? '1.0.x' : versionRule; this.entries = new Map<String, T>(); this.scriptedEntryIds = []; // Lazy initialization of singletons should let this get called, // but we have this check just in case. if (FlxG.game != null) { FlxG.console.registerObject('registry$registryId', this); } } /** * TODO: Create a `loadEntriesAsync(onProgress, onComplete)` function. */ public function loadEntries():Void { clearEntries(); // // SCRIPTED ENTRIES // var scriptedEntryClassNames:Array<String> = getScriptedClassNames(); log('Parsing ${scriptedEntryClassNames.length} scripted entries...'); for (entryCls in scriptedEntryClassNames) { var entry:Null<T> = null; try { entry = createScriptedEntry(entryCls); } catch (e) { log('Failed to create scripted entry (${entryCls})'); continue; } if (entry != null) { log('Successfully created scripted entry (${entryCls} = ${entry.id})'); entries.set(entry.id, entry); scriptedEntryIds.set(entry.id, entryCls); } else { log('Failed to create scripted entry (${entryCls})'); } } // // UNSCRIPTED ENTRIES // var entryIdList:Array<String> = DataAssets.listDataFilesInPath('${dataFilePath}/'); var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool { return !entries.exists(entryId); }); log('Parsing ${unscriptedEntryIds.length} unscripted entries...'); for (entryId in unscriptedEntryIds) { try { var entry:T = createEntry(entryId); if (entry != null) { trace(' Loaded entry data: ${entry}'); entries.set(entry.id, entry); } } catch (e) { // Print the error. trace(' Failed to load entry data: ${entryId}'); trace(e); continue; } } } /** * Retrieve a list of all entry IDs in this registry. * @return The list of entry IDs. */ public function listEntryIds():Array<String> { return entries.keys().array(); } /** * Count the number of entries in this registry. * @return The number of entries. */ public function countEntries():Int { return entries.size(); } /** * Return whether the entry ID is known to have an attached script. * @param id The ID of the entry. * @return `true` if the entry has an attached script, `false` otherwise. */ public function isScriptedEntry(id:String):Bool { return scriptedEntryIds.exists(id); } /** * Return the class name of the scripted entry with the given ID, if it exists. * @param id The ID of the entry. * @return The class name, or `null` if it does not exist. */ public function getScriptedEntryClassName(id:String):String { return scriptedEntryIds.get(id); } /** * Return whether the registry has successfully parsed an entry with the given ID. * @param id The ID of the entry. * @return `true` if the entry exists, `false` otherwise. */ public function hasEntry(id:String):Bool { return entries.exists(id); } /** * Fetch an entry by its ID. * @param id The ID of the entry to fetch. * @return The entry, or `null` if it does not exist. */ public function fetchEntry(id:String):Null<T> { return entries.get(id); } public function toString():String { return 'Registry(' + registryId + ', ${countEntries()} entries)'; } /** * Retrieve the data for an entry and parse its Semantic Version. * @param id The ID of the entry. * @return The entry's version, or `null` if it does not exist or is invalid. */ public function fetchEntryVersion(id:String):Null<thx.semver.Version> { var entryStr:String = loadEntryFile(id).contents; var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } function log(message:String):Void { trace('[' + registryId + '] ' + message); } function loadEntryFile(id:String):JsonFile { var entryFilePath:String = Paths.json('${dataFilePath}/${id}'); var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); return { fileName: entryFilePath, contents: rawJson }; } function clearEntries():Void { for (entry in entries) { entry.destroy(); } entries.clear(); } // // FUNCTIONS TO IMPLEMENT // /** * Read, parse, and validate the JSON data and produce the corresponding data object. * * NOTE: Must be implemented on the implementation class. * @param id The ID of the entry. * @return The created entry. */ public abstract function parseEntryData(id:String):Null<J>; /** * Parse and validate the JSON data and produce the corresponding data object. * * NOTE: Must be implemented on the implementation class. * @param contents The JSON as a string. * @param fileName An optional file name for error reporting. * @return The created entry. */ public abstract function parseEntryDataRaw(contents:String, ?fileName:String):Null<J>; /** * Read, parse, and validate the JSON data and produce the corresponding data object, * accounting for old versions of the data. * * NOTE: Extend this function to handle migration. * @param id The ID of the entry. * @param version The entry's version (use `fetchEntryVersion(id)`). * @return The created entry. */ public function parseEntryDataWithMigration(id:String, version:Null<thx.semver.Version>):Null<J> { if (version == null) { throw '[${registryId}] Entry ${id} could not be JSON-parsed or does not have a parseable version.'; } // If a version rule is not specified, do not check against it. if (versionRule == null || VersionUtil.validateVersion(version, versionRule)) { return parseEntryData(id); } else { throw '[${registryId}] Entry ${id} does not support migration to version ${versionRule}.'; } /* * An example of what you should override this with: * * ```haxe * if (VersionUtil.validateVersion(version, "0.1.x")) { * return parseEntryData_v0_1_x(id); * } else { * super.parseEntryDataWithMigration(id, version); * } * ``` */ } /** * Retrieve the list of scripted class names to load. * @return An array of scripted class names. */ abstract function getScriptedClassNames():Array<String>; /** * Create an entry from the given ID. * @param id */ function createEntry(id:String):Null<T> { // We enforce that T is Constructible to ensure this is valid. return new T(id); } /** * Create a entry, attached to a scripted class, from the given class name. * @param clsName */ abstract function createScriptedEntry(clsName:String):Null<T>; function printErrors(errors:Array<json2object.Error>, id:String = ''):Void { trace('[${registryId}] Failed to parse entry data: ${id}'); for (error in errors) { DataError.printError(error); } } }