An extremely simple yet robust installable module system for Godot.
The module system allows for scanning at runtime the installed modules,
specifying the order in which they should be loaded, and reporting errors
with said modules. Once the modules are loaded, the API uses a single, simple
get_implementation(String::extension_point)
method to access the extensible
parts that modules can implement.
The component includes default GUI components for showing errors in module loading, and for selecting the module order.
Category lib
, gui
Requires error_codes, progress_listener, error_dialog
The modules component expects the modules to be accessible either through
the built-in game directories (res://
) or through the user directory
(user://
). You should declare one directory in at least one of these
places as being the designated "module" directory (which we'll call
root module directories).
The module directory contains a list of directories which should each be a
separate module. The modules themselves need to have a module.json
object
that describes the metadata regarding itself.
The root module directories can contain a file named module-list.txt
which
lists, once per line, each sub-directory which is a designated module. This
helps with systems that have slow or not-supported file system scanning. If
the file doesn't exist, then each sub-directory is checked for the module.json
file, indicating a module.
Each module directory is free to store whatever it needs within its directory.
All that's required is the module.json
metadata file.
The module.json
file is a JSon formatted file that must have several
defined parts, with some optional parts. If a keyword is not known,
then it's assumed to be a comment (there are two exceptions to this, marked
clearly below).
Example file:
{
"name": "core extensions",
"version": [ 1, 0 ],
"description": "Core program extensible parts.",
"classname": "core_module.gd",
"translations": [
"translations.en.xl",
"translations.es.xl"
],
"requires": [
{
"module": "core",
"min": 1,
"max": 2
}
],
"calls-description": "Parts of this module that other modules can implement, or parts that this module requires that other modules implement. These are essentially 'callouts'.",
"calls": {
"init/game": {
"description": "adds data to a game when this module is added.",
"type": "callback",
"aggregate": "sequential"
},
"init/scene": {
"description": "initial game-start UI (scene file)",
"type": "path",
"aggregate": "none"
},
"start-game/intro-text": {
"description": "the informational text when starting a new game",
"type": "string",
"aggregate": "first"
}
},
"implements-description": "Existing callout points that this extends.",
"implements": {
"init/game": {
"type": "callback",
"function": "_on_init_game"
},
"init/scene": {
"type": "path",
"path": "scenes/init.xscn"
},
"start-game/intro-text": {
"type": "string",
"value": "INTRO_TEXT"
}
}
}
Parts of the file:
The name of the module. It generally matches the directory name, but doesn't have to. This is the form of the module name that the user will see when ordering modules or viewing errors.
A list of 2 integers representing the major and minor version numbers. The version numbers represent the major and minor revision number, respectively. The major version number is used by other modules when specifying the required version number ranges.
Other parts of the game that use the version number should keep to looking at the major version number.
A textual description for the purpose of the module. Used to display more information to the user.
The GDScript file name of the object that processes the module logic. It must be within the module directory directly, and not in a sub-directory.
If this value isn't specified, then the default modules/module.md
class is
used instead.
The class object can provide the deactivate()
and activate(ext_point_access)
methods. Note that the activate
method will only be called if the
deactivate
method exists. When the module is loaded into an ordered active
set, the activate
method will be called with a single parameter object
that provides the one method get_implementation(String::extension_point_name) : Variant
.
If the module caches this object, then the cached version must be cleared
out when deactivate
is called.
The class object is also used for callback
extension points, and is available
for custom extension point types to use.
The class will be created using a simple new()
operation, so any _init()
function on the class must have zero arguments.
An array of translation files that this module uses. These names are relative to the module directory. When the module is loaded into the active list, its translation is also loaded; likewise, when it is unloaded as active, its translation is also unloaded.
A list of dictionaries that declares which required modules need to be installed
and ordered before this one. Each element in the requires
array is a
dictionary with these fields:
- module (String, required) - the required module name (as set by that module's metadata.
- min (int, optional) - minimum major version number of the required module name.
- max (int, optional) - maximum major version number of the required module name.
If min
is not specified, then the minimum version defaults to 0. If max
is not specified, then the maximum version defaults to 2147483646.
If the module's requirements are not fulfilled by the installed modules, then the component will report that module as invalid.
Defines an extension point that the module calls into. Extension points have a
name (which is passed to the top-level get_implementation(String)
function),
and a definition of the returned value type. These are how the modules define
where other modules can extend the functionality.
Every key in this dictionary indicates an extension point; there is no ignored key in this structure.
The key in the dictionary declares a new extension point. The names can take any form, but the standard approach is to split it like a directory name. Each extension point defines these attributes:
- description (String, required) - a textual description for what the extension point does.
- type (String, required) - one of the list of supported types.
- aggregate (String, optional) - how the type handles multiple modules implementing the same extension point.
- order (String, optional) - how to sort results generated by multiple modules implementing the extension point.
The modules component provides a set of built-in types, but these can be
expanded upon by invoking add_extension_point_type()
. The meaning of the
order and aggregate attributes depends upon the type.
If multiple modules define the same extension point, then they must both match, or the last one loaded is marked as invalid.
Lists the extension points that this module implements. The listed extension points must match the type of the declared call point.
Every key in this dictionary indicates an extension point; there is no ignored key in this structure.
The key of the dictionary declares which extension point it implements. The
value must contain the type
entry, which must match the type of the extension
point. The other values in the dictionary must conform to the requirements of
that type.
There are some default extension point types, but you can also register your own extension point type.
Associates a list of strings with the extension point.
The type order
value can be "reverse", "asc", "desc", and "normal" (defaults
to "normal"). The type aggregate
value can be "none", "first", "last",
"list", and "set". When the list of strings from all the modules is grouped
together, they are ordered by the order
value, then aggregated. So, if
one module returns a list of 5 strings, then the second one returns a list of
3 strings, the total grouping of strings passed into ordering is all 8 strings
in a single list, in the order returned by the modules.
The string
implementation has the additional key-value pair, value
, which
is either a String or an array of Strings.
normal
: does not change the order of the values.reverse
: reverses the order of the values.asc
: sorts the values ascending (alphabetical, A-Z)desc
: sorts the values descending (reverse alphabetical, Z-A)
first
andnone
: returns one string, the first value in the ordered list.last
: returns one string, the last value in the ordered list.list
: returns all the values in the the ordered list.set
: returns the unique values from the list (no duplicates, order is not guaranteed).
A path type works just like the string
type, except that the module directory
is prepended to each value. So, if the module "res://modules/core-module"
specifies a path
implementation value "scenes/first.xscn", the resulting value
is "res://modules/core-module/scenes/first.xscn".
The path implementation uses the key-value pair path
. The value can be either
a string or an array of strings.
Defines a function on the module object (defined by the classname
above)
to return. The extension point will return a lsit of funtion reference values.
The type declaration's order
value can be "reverse" and "normal" (defaults
to "normal"). The type aggregate
value can be "none", "first", "last",
"sequential", and "chain". When the list of functions from all the modules is
grouped together, they are ordered by the order
value, then aggregated.
first
andnone
: returns the first function reference in the ordered list.last
: returns the last function reference in the ordered list.sequential
: returns a function reference that in turn calls each ordered function sequentially.chain
: same assequential
, but it passes the returned value from the previous call as the first argument into the next call. This allows for augmenting or vetoing results.
Custom extension point types can be registered through the module class's
add_extension_point_type(String::type_name, Variant::type_obj)
method.
They need to be registered under a unique type name, and the type object
must implement these functions:
class MyCustomCallpointType:
func validate_call_decl(point):
# Ensure the extension point declaration (under the "calls" group) is
# valid. Returns a boolean value.
return (!("order" in point) || point.order in [ "normal", "reverse" ])\
&& (point.aggregate in [ "none", "first", "last" ])
func validate_implement(ext, ms):
# Checks whether the extension point implementation (under the "calls"
# group) is valid. Returns a boolean value.
return ms.object.has_method("convert") && \
"position" in ext && typeof(ext.position) == TYPE_ARRAY && \
ext.position.size() == 2 && typeof(ext.position[0]) == TYPE_INT && \
typeof(ext.position[1]) == TYPE_INT
func convert_type(ext, ms):
# Convert the extension point implementation (under the "calls" group)
# into the expected value.
return ms.object.convert(Vector2(ext.position[0], ext.position[1]))
func aggregate(point, values):
# Aggregate the list of values from the convert_type return value
# into a new value.
if point.order == "reverse":
values.invert()
if point.aggregate == "none" || point.aggregate == "first":
return values[0]
elif point.aggregate == "last":
return values[values.size() - 1]
else:
# invalid
return null
The modules object needs to be created and initialized.
var modules = preload("res://bootstrap/lib/modules.gd").new()
modules.add_extension_point_type("custom_type", CustomTypeCallpoint.new())
Then, the modules object needs to scan for installed modules:
modules.reload_modules(["res://modules", "user://modules"], progress_bar)
Some of the modules might be invalid. You can use the GUI module to display the error message.
var invalid_modules = modules.get_invalid_modules()
if ! invalid_modules.empty():
var n = load("res://bootstrap/gui/modules/problem.xscn").instance()
n.setup(bad_modules)
parent.add_child(n)
At some point, the code will need to be aware of the correct module ordering. Once this is discovered, and the modules should be activated, you can load the modules.
var all_modules = modules.get_installed_modules()
if modules.get_installed_module("core", 0, 5) == null:
show_error("core module (version 0 to 5) does not exist or is invalid!")
return
var order = [ "core", "dlc", "user-gui-mod" ]
var active_modules = modules.create_active_module_list(order, progress_bar)
if active_modules.is_invalid():
show_error("problems loading modules")
return
The module system is designed around having only one active module list
at a time (due to the translation services). If you need to reload the modules,
you can invoke create_active_module_list
again, and the first list will become
invalid.
Creates a new modules instance. Even though you can create multiple of these, you should take care to use only one - activating modules registers translations, which could lead to trouble if multiple instances of the same module are loaded or unloaded.
Registers a new extension point type for modules to use.
Initializes the list of installed modules. If the modules are already loaded, then this will not do anything.
Reloads the modules, reusing the existing module path. If the modules are already loaded, this will unload them and rescan for modules. Be careful with this method - it can cause active modules to be wiped out.
Returns the list of all the modules that were found through the initialize()
method. These may or may not be valid modules. The returned structure has
the following values:
name
(String) - name of the moduledir
(String) - directory where the module lives.raw_name
(String) - name of the module directoryversion
([int,int]
)- list of 2 ints, the major (index 0) and minor (index 1) version.description
(String) - textual long description of the module.classname
(String) - resource location for the module (a GDScript file)error_code
(int) - error code.OK
if the module is valid.error_operation
(String) - part of the module loading that discovered the issue.null
iferror_code
isOK
.error_details
(String) - details about what caused the module loading issue.translations
([String,...]) - list of all the translation resource files that the module uses.requires
([{...}, ...]) - list of required modules.extension_points
({...}
) - dictionary of declared extension points.implement_points
({...}
) - dictionary of implemented extension points.class_object
(Resource) - loaded resource of theclassname
value.object
(Variant) - instantiatedclass_object
.
func get_installed_module(String::module_name, int::min_major_version, int::max_major_version, boolean::allow_errors = false) : ModuleStruct
Returns the installed module with the given name, whose major version number
is in the range [ min_major_version, max_major_version ]
(inclusive). If
allow_errors
is false, then the module's error_code
value must be OK
,
otherwise the module can be returned if it isn't valid. If no matching
module is found, returns null.
Returns all modules that have an error_code
value unequal to OK
.
func create_active_module_list(Array[String]::module_names, Range::progress = null) : OrderedModules
Adds all the modules in the module_names
list into an OrderedModules
object,
registers them, and returns the object for use.
If there is currently an active modules list, it is first unloaded.
The object that allows access to the currently active and registered modules.
Returns the value for the extension point name, as returned by the modules. The actual value is dependent upon the extension point type.
Returns false
if the active modules was unloaded, or if there is at least one
module that is invalid, otherwise true
.
Returns true
if the active modules was unloaded, or if there is at least one
module that is invalid, otherwise false
.
Returns the list of all the modules that have an error.
Returns the ordered list of all the active modules.
Unloads this active module and makes it invalid.