## This resource works in two stages: ## 1. loads resource packs from a specified location ## 2. verifies each resource pack is conform to a spec ## 3. list all plugins ## 4. loads all plugins marked as "enabled" ## ## Notes: Enabling a plugin does _not_ run it. It's expected that the app would ## be restarted for the loading to happen. If you want to immediately load and ## unload, use the signals ## ## ## Expected Flow: ## ## 1. use `PluginLoader.restore().load_plugins()` to load all plugins ## 2. use `PluginLoader.restore().provide_interface()` to get a bunch of ## checkboxes to use in an UI ## 3. that's it! You can also provide an initial set of plugins (expected to be ## found with the app's files class_name PluginLoader extends Resource ################################################################################ # # GENERAL SINGLETON BEHAVIOR # ## Default resource path. const _RESOURCE_PATH := "user://plugins.tres" ## Loads the plugin loader resource. ## Always returns a PluginLoader instance; if the path is wrong, a new instance will ## be created. static func restore(path := _RESOURCE_PATH) -> PluginLoader: var file: PluginLoader = ResourceLoader.load(path, "PluginLoader") if file == null: return PluginLoader.new() file.refresh_from_disk() return file ## Saves the current plugins status. ## This is called automatically when changing a plugin status. You shouldn't have to ## call it manually. func save() -> void: ResourceSaver.save(self, _RESOURCE_PATH) ################################################################################ # # PLUGIN PACKS LOADING # enum STATUS{ AVAILABLE, LOADED, ERROR, NOT_FOUND } ## Defines where plugin packs should be fetched from @export var plugins_packs_directory := "user://" ## An object of [plugin_name: String, status: STATUS] @export var plugins_status := {} ## These represent plugins that come with the application @export var native_plugins := {} ## Lists all plugin packs from their designated location. Refreshes the "plugins ## status" array ## Returns a Dictionary with keys "added" and "removed", each respectively listing ## all added or removed packs as strings func read_available_plugins() -> Dictionary: var dir := DirAccess.open(plugins_packs_directory) if dir == null: return {} dir.list_dir_begin() var file_name = dir.get_next() var old_plugins := plugins_status.duplicate() plugins_status = {} var removed: Array[String] = [] var added: Array[String] = [] # read the resource packs directory while file_name != "": if not dir.current_is_dir(): if file_name.get_extension().to_lower() == "pck": var plugin_name := file_name.get_basename() var path := plugins_packs_directory.path_join(file_name) var success := ProjectSettings.load_resource_pack(path, false) if not success: printerr("Could not load resource pack %s"%[file_name]) else: if old_plugins.has(plugin_name): plugins_status[plugin_name] = old_plugins[plugin_name] else: added.append(plugin_name) plugins_status[plugin_name] = STATUS.AVAILABLE file_name = dir.get_next() # read the native plugins list for plugin_name in native_plugins: if not old_plugins.has(plugin_name): plugins_status[plugin_name] = old_plugins[plugin_name] else: plugins_status[plugin_name] = native_plugins[plugin_name] # check if anything was removed for plugin_name in old_plugins: if not plugins_status.has(plugin_name): removed.append(plugin_name) save() return { added = added, removed = removed, } ################################################################################ # # PLUGINS STATES # # Changing a plugin state does not mean anything before calling `load_plugins()` # signal plugin_status_changed(plugin_name: String, status: STATUS) ## Sets a plugin on or off. Does not immediately load it. func set_plugin_status(plugin_name: String, status: STATUS) -> void: if not plugins_status.has(plugin_name): printerr("Plugin %s does not exist"%[plugin_name]) return if plugins_status[plugin_name] == status: return plugins_status[plugin_name] = status plugin_status_changed.emit(plugin_name, status) save() ## Returns a plugin status. func get_plugin_status(plugin_name: String) -> STATUS: if not plugins_status.has(plugin_name): printerr("Plugin %s does not exist"%[plugin_name]) return STATUS.NOT_FOUND return (plugins_status[plugin_name]) as STATUS ################################################################################ # # PLUGINS INSTANCING # ## Defines where plugins are expected to be found after a plugin pack is loaded ## this is also where native plugins are located @export var plugins_local_directory := "res://mods" signal plugin_error(plugin_name: String) signal plugin_started(plugin_name: String) func start_plugin_if_enabled(plugin_name: String) -> bool: var status := get_plugin_status(plugin_name) match status: STATUS.LOADED: var instance := instance_plugin_if_valid(plugin_name) if instance == null: plugins_status[plugin_name] = STATUS.ERROR plugin_error.emit(plugin_name) else: instance.plugin_start() plugin_started.emit(plugin_name) return true return false ## This loads a local plugin and instances it. If the plugin comes from a resource pack, ## the resource pack needs to have been loaded before func instance_plugin_if_valid(plugin_name: String) -> Plugin_V1: var entry_point_path := plugins_local_directory.path_join(plugin_name).path_join("plugin.gd") var entry_point_file := FileAccess.open(entry_point_path, FileAccess.READ) if entry_point_file == null: printerr("The entry point does not exist or is not readable") return null var entry_point_text := entry_point_file.get_as_text() if not entry_point_text.match("extends"): printerr("The entry point is not text, which is not allowed for security reasons") return null if entry_point_text.match("func _init():"): printerr("The entry point contains _init(), which is a security issue, mod will not load") return null var entry_point_script = load(entry_point_path) if entry_point_script == null: printerr("Tried loading %s but did not find the expected entry point"%[entry_point_path]) return null var instance: Plugin_V1 = (entry_point_script as Plugin_V1).new() if not (instance is Plugin_V1): printerr("Tried loading %s but it is not the right format"%[entry_point_path]) return null return instance ################################################################################ # # UTILITIES # ## Utility function to provide a list of checkboxes that turn plugins on or off. func provide_interface() -> Array[CheckBox]: var checkboxes: Array[CheckBox] = [] for plugin in plugins_status: var plugin_name: String = plugin as String var checkbox := CheckBox.new() checkbox.text = plugin_name.get_basename() checkbox.button_pressed = get_plugin_status(plugin_name) == STATUS.LOADED checkbox.toggled.connect( func checkbox_toggled(is_on: bool) -> void: set_plugin_status(plugin_name, STATUS.LOADED if is_on else STATUS.AVAILABLE) ) checkboxes.append(checkbox) return checkboxes ################################################################################ # # BOOSTRAPPING # ## For each plugin that's on, load it. ## This function makes no assumptions and no checks. Not loading plugins twice ## is on you func load_plugins() -> void: var report := read_available_plugins() var removed := report.removed as Array[String] var added := report.added as Array[String] if removed.size(): print_rich("Removed plugins %s"%[", ".join(removed)]) if added.size(): print_rich("Added plugins %s"%[", ".join(removed)]) for plugin_name in plugins_status: start_plugin_if_enabled(plugin_name)