added the game

This commit is contained in:
JHDev2006
2025-09-13 16:30:32 +01:00
parent 5ef689109b
commit 3773bdaf64
3616 changed files with 263702 additions and 0 deletions

View File

@@ -0,0 +1,410 @@
class_name ModLoaderConfig
extends Object
##
## Class for managing per-mod configurations.
##
## @tutorial(Creating a Mod Config Schema with JSON-Schemas): https://wiki.godotmodding.com/guides/modding/config_json/
const LOG_NAME := "ModLoader:Config"
const DEFAULT_CONFIG_NAME := "default"
## Creates a new configuration for a mod.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## - [param config_name] ([String]): The name of the configuration.[br]
## - [param config_data] ([Dictionary]): The configuration data to be stored.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The created [ModConfig] object if successful, or null otherwise.
static func create_config(mod_id: String, config_name: String, config_data: Dictionary) -> ModConfig:
var default_config: ModConfig = get_default_config(mod_id)
if not default_config:
ModLoaderLog.error(
"Failed to create config \"%s\". No config schema found for \"%s\"."
% [config_name, mod_id], LOG_NAME
)
return null
# Make sure the config name is not empty
if config_name == "":
ModLoaderLog.error(
"Failed to create config \"%s\". The config name cannot be empty."
% config_name, LOG_NAME
)
return null
# Make sure the config name is unique
if ModLoaderStore.mod_data[mod_id].configs.has(config_name):
ModLoaderLog.error(
"Failed to create config \"%s\". A config with the name \"%s\" already exists."
% [config_name, config_name], LOG_NAME
)
return null
# Create the config save path based on the config_name
var config_file_path := _ModLoaderPath.get_path_to_mod_configs_dir(mod_id).path_join("%s.json" % config_name)
# Initialize a new ModConfig object with the provided parameters
var mod_config := ModConfig.new(
mod_id,
config_data,
config_file_path
)
# Check if the mod_config is valid
if not mod_config.is_valid:
return null
# Store the mod_config in the mod's ModData
ModLoaderStore.mod_data[mod_id].configs[config_name] = mod_config
# Save the mod_config to a new config JSON file in the mod's config directory
var is_save_success := mod_config.save_to_file()
if not is_save_success:
return null
ModLoaderLog.debug("Created new config \"%s\" for mod \"%s\"" % [config_name, mod_id], LOG_NAME)
return mod_config
## Updates an existing [ModConfig] object with new data and saves the config file.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object to be updated.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The updated [ModConfig] object if successful, or null otherwise.
static func update_config(config: ModConfig) -> ModConfig:
# Validate the config and check for any validation errors
var error_message := config.validate()
# Check if the config is the "default" config, which cannot be modified
if config.name == DEFAULT_CONFIG_NAME:
ModLoaderLog.error("The \"default\" config cannot be modified. Please create a new config instead.", LOG_NAME)
return null
# Check if the config passed validation
if not config.is_valid:
ModLoaderLog.error("Update for config \"%s\" failed validation with error message \"%s\"" % [config.name, error_message], LOG_NAME)
return null
# Save the updated config to the config file
var is_save_success := config.save_to_file()
if not is_save_success:
ModLoaderLog.error("Failed to save config \"%s\" to \"%s\"." % [config.name, config.save_path], LOG_NAME)
return null
# Return the updated config
return config
## Deletes a [ModConfig] object and performs cleanup operations.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object to be deleted.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True if the deletion was successful, False otherwise.
static func delete_config(config: ModConfig) -> bool:
# Check if the config is the "default" config, which cannot be deleted
if config.name == DEFAULT_CONFIG_NAME:
ModLoaderLog.error("Deletion of the default configuration is not allowed.", LOG_NAME)
return false
# Change the current config to the "default" config
set_current_config(get_default_config(config.mod_id))
# Remove the config file from the Mod Config directory
var is_remove_success := config.remove_file()
if not is_remove_success:
return false
# Remove the config from ModData
ModLoaderStore.mod_data[config.mod_id].configs.erase(config.name)
return true
## Sets the current configuration of a mod to the specified configuration.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object to be set as current config.
static func set_current_config(config: ModConfig) -> void:
ModLoaderStore.mod_data[config.mod_id].current_config = config
## Returns the schema for the specified mod id.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - A dictionary representing the schema for the mod's configuration file.
static func get_config_schema(mod_id: String) -> Dictionary:
# Get all config files for the specified mod
var mod_configs := get_configs(mod_id)
# If no config files were found, return an empty dictionary
if mod_configs.is_empty():
return {}
# The schema is the same for all config files, so we just return the schema of the default config file
return mod_configs.default.schema
## Retrieves the schema for a specific property key.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object from which to retrieve the schema.[br]
## - [param prop] ([String]): The property key for which to retrieve the schema.[br]
## [br]
## [b]Returns:[/b][br]
## - [Dictionary]: The schema dictionary for the specified property.
static func get_schema_for_prop(config: ModConfig, prop: String) -> Dictionary:
# Split the property string into an array of property keys
var prop_array := prop.split(".")
# If the property array is empty, return the schema for the root property
if prop_array.is_empty():
return config.schema.properties[prop]
# Traverse the schema dictionary to find the schema for the specified property
var schema_for_prop := _traverse_schema(config.schema.properties, prop_array)
# If the schema for the property is empty, log an error and return an empty dictionary
if schema_for_prop.is_empty():
ModLoaderLog.error("No Schema found for property \"%s\" in config \"%s\" for mod \"%s\"" % [prop, config.name, config.mod_id], LOG_NAME)
return {}
return schema_for_prop
# Recursively traverses the schema dictionary based on the provided [code]prop_key_array[/code]
# and returns the corresponding schema for the target property.[br]
# [br]
# [b]Parameters:[/b][br]
# - [param schema_prop]: The current schema dictionary to traverse.[br]
# - [param prop_key_array]: An array containing the property keys representing the path to the target property.[br]
# [br]
# [b]Returns:[/b][br]
# - [Dictionary]: The schema dictionary corresponding to the target property specified by the [code]prop_key_array[/code].
# If the target property is not found, an empty dictionary is returned.
static func _traverse_schema(schema_prop: Dictionary, prop_key_array: Array) -> Dictionary:
# Return the current schema_prop if the prop_key_array is empty (reached the destination property)
if prop_key_array.is_empty():
return schema_prop
# Get and remove the first prop_key in the array
var prop_key: String = prop_key_array.pop_front()
# Check if the searched property exists
if not schema_prop.has(prop_key):
return {}
schema_prop = schema_prop[prop_key]
# If the schema_prop has a 'type' key, is of type 'object', and there are more property keys remaining
if schema_prop.has("type") and schema_prop.type == "object" and not prop_key_array.is_empty():
# Set the properties of the object as the current 'schema_prop'
schema_prop = schema_prop.properties
schema_prop = _traverse_schema(schema_prop, prop_key_array)
return schema_prop
## Retrieves an Array of mods that have configuration files.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An Array containing the mod data of mods that have configuration files.
static func get_mods_with_config() -> Array:
# Create an empty array to store mods with configuration files
var mods_with_config := []
# Iterate over each mod in ModLoaderStore.mod_data
for mod_id in ModLoaderStore.mod_data:
# Retrieve the mod data for the current mod ID
# *The ModData type cannot be used because ModData is not fully loaded when this code is executed.*
var mod_data = ModLoaderStore.mod_data[mod_id]
# Check if the mod has any configuration files
if not mod_data.configs.is_empty():
mods_with_config.push_back(mod_data)
# Return the array of mods with configuration files
return mods_with_config
## Retrieves the configurations dictionary for a given mod ID.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id]: The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [Dictionary]: A dictionary containing the configurations for the specified mod.
## If the mod ID is invalid or no configurations are found, an empty dictionary is returned.
static func get_configs(mod_id: String) -> Dictionary:
# Check if the mod ID is invalid
if not ModLoaderStore.mod_data.has(mod_id):
ModLoaderLog.fatal("Mod ID \"%s\" not found" % [mod_id], LOG_NAME)
return {}
var config_dictionary: Dictionary = ModLoaderStore.mod_data[mod_id].configs
# Check if there is no config file for the mod
if config_dictionary.is_empty():
ModLoaderLog.debug("No config for mod id \"%s\"" % mod_id, LOG_NAME, true)
return {}
return config_dictionary
## Retrieves the configuration for a specific mod and configuration name.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## - [param config_name] ([String]): The name of the configuration.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The configuration as a [ModConfig] object or null if not found.
static func get_config(mod_id: String, config_name: String) -> ModConfig:
var configs := get_configs(mod_id)
if not configs.has(config_name):
ModLoaderLog.error("No config with name \"%s\" found for mod_id \"%s\" " % [config_name, mod_id], LOG_NAME)
return null
return configs[config_name]
## Checks whether a mod has a current configuration set.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True if the mod has a current configuration, false otherwise.
static func has_current_config(mod_id: String) -> bool:
var mod_data := ModLoaderMod.get_mod_data(mod_id)
return not mod_data.current_config == null
## Checks whether a mod has a configuration with the specified name.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## - [param config_name] ([String]): The name of the configuration.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True if the mod has a configuration with the specified name, False otherwise.
static func has_config(mod_id: String, config_name: String) -> bool:
var mod_data := ModLoaderMod.get_mod_data(mod_id)
return mod_data.configs.has(config_name)
## Retrieves the default configuration for a specified mod ID.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The [ModConfig] object representing the default configuration for the specified mod.
## If the mod ID is invalid or no configuration is found, returns null.
static func get_default_config(mod_id: String) -> ModConfig:
return get_config(mod_id, DEFAULT_CONFIG_NAME)
## Retrieves the currently active configuration for a specific mod.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The configuration as a [ModConfig] object or [code]null[/code] if not found.
static func get_current_config(mod_id: String) -> ModConfig:
var current_config_name := get_current_config_name(mod_id)
var current_config: ModConfig
# Load the default configuration if there is no configuration set as current yet
# Otherwise load the corresponding configuration
if current_config_name.is_empty():
current_config = get_default_config(mod_id)
else:
current_config = get_config(mod_id, current_config_name)
return current_config
## Retrieves the name of the current configuration for a specific mod.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [String] The currently active configuration name for the given mod id or an empty string if not found.
static func get_current_config_name(mod_id: String) -> String:
# Check if user profile has been loaded
if not ModLoaderStore.current_user_profile or not ModLoaderStore.user_profiles.has(ModLoaderStore.current_user_profile.name):
# Warn and return an empty string if the user profile has not been loaded
ModLoaderLog.warning("Can't get current mod config name for \"%s\", because no current user profile is present." % mod_id, LOG_NAME)
return ""
# Retrieve the current user profile from ModLoaderStore
# *Can't use ModLoaderUserProfile because it causes a cyclic dependency*
var current_user_profile = ModLoaderStore.current_user_profile
# Check if the mod exists in the user profile's mod list and if it has a current config
if not current_user_profile.mod_list.has(mod_id) or not current_user_profile.mod_list[mod_id].has("current_config"):
# Log an error and return an empty string if the mod has no config file
ModLoaderLog.error("Can't get current mod config name for \"%s\" because no config file exists." % mod_id, LOG_NAME)
return ""
# Return the name of the current configuration for the mod
return current_user_profile.mod_list[mod_id].current_config
## Refreshes the data of the provided configuration by reloading it from the config file.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object whose data needs to be refreshed.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The [ModConfig] object with refreshed data if successful, or the original object otherwise.
static func refresh_config_data(config: ModConfig) -> ModConfig:
# Retrieve updated configuration data from the config file
var new_config_data := _ModLoaderFile.get_json_as_dict(config.save_path)
# Update the data property of the ModConfig object with the refreshed data
config.data = new_config_data
return config
## Iterates over all mods to refresh the data of their current configurations, if available.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## Compares the previous configuration data with the refreshed data and emits the [signal ModLoader.current_config_changed]
## signal if changes are detected.[br]
## This function ensures that any changes made to the configuration files outside the application
## are reflected within the application's runtime, allowing for dynamic updates without the need for a restart.
static func refresh_current_configs() -> void:
for mod_id in ModLoaderMod.get_mod_data_all().keys():
# Skip if the mod has no config
if not has_current_config(mod_id):
return
# Retrieve the current configuration for the mod
var config := get_current_config(mod_id)
# Create a deep copy of the current configuration data for comparison
var config_data_previous := config.data.duplicate(true)
# Refresh the configuration data
var config_new := refresh_config_data(config)
# Compare previous data with refreshed data
if not config_data_previous == config_new.data:
# Emit signal indicating that the current configuration has changed
ModLoader.current_config_changed.emit(config)

View File

@@ -0,0 +1 @@
uid://byhbvq7il70cy

View File

@@ -0,0 +1,76 @@
class_name ModLoaderDeprecated
extends Object
##
## API methods for deprecating funcs. Can be used by mods with public APIs.
const LOG_NAME := "ModLoader:Deprecated"
## Marks a method that has changed its name or class.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param old_method] ([String]): The name of the deprecated method.[br]
## - [param new_method] ([String]): The name of the new method to use.[br]
## - [param since_version] ([String]): The version number from which the method has been deprecated.[br]
## - [param show_removal_note] ([bool]): (optional) If true, includes a note about future removal of the old method. Default is true.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func deprecated_changed(old_method: String, new_method: String, since_version: String, show_removal_note: bool = true) -> void:
_deprecated_log(str(
"DEPRECATED: ",
"The method \"%s\" has been deprecated since version %s. " % [old_method, since_version],
"Please use \"%s\" instead. " % new_method,
"The old method will be removed with the next major update, and will break your code if not changed. " if show_removal_note else ""
))
## Marks a method that has been entirely removed, with no replacement.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param old_method] ([String]): The name of the removed method.[br]
## - [param since_version] ([String]): The version number from which the method has been deprecated.[br]
## - [param show_removal_note] ([bool]): (optional) If true, includes a note about future removal of the old method. Default is true.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## ===[br]
## [b]Note:[/b][br]
## This should rarely be needed but is included for completeness.[br]
## ===[br]
static func deprecated_removed(old_method: String, since_version: String, show_removal_note: bool = true) -> void:
_deprecated_log(str(
"DEPRECATED: ",
"The method \"%s\" has been deprecated since version %s, and is no longer available. " % [old_method, since_version],
"There is currently no replacement method. ",
"The method will be removed with the next major update, and will break your code if not changed. " if show_removal_note else ""
))
## Marks a method with a freeform deprecation message.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param msg] ([String]): The deprecation message.[br]
## - [param since_version] ([String]): (optional) The version number from which the deprecation applies.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func deprecated_message(msg: String, since_version: String = "") -> void:
var since_text := " (since version %s)" % since_version if since_version else ""
_deprecated_log(str("DEPRECATED: ", msg, since_text))
# Internal function for logging deprecation messages with support to trigger warnings instead of fatal errors.[br]
# [br]
# [b]Parameters:[/b][br]
# - [param msg] ([String]): The deprecation message.[br]
# [br]
# [b]Returns:[/b][br]
# - No return value[br]
static func _deprecated_log(msg: String) -> void:
if ModLoaderStore and ModLoaderStore.ml_options.ignore_deprecated_errors or OS.has_feature("standalone"):
ModLoaderLog.warning(msg, LOG_NAME, true)
else:
ModLoaderLog.fatal(msg, LOG_NAME, true)

View File

@@ -0,0 +1 @@
uid://2sifoxblubxv

View File

@@ -0,0 +1,83 @@
class_name ModLoaderHookChain
extends RefCounted
## Small class to keep the state of hook execution chains and move between mod hook calls.[br]
## For examples, see [method ModLoaderMod.add_hook].
## The reference object is usually the [Node] that the vanilla script is attached to. [br]
## If the hooked method is [code]static[/code], it will contain the [GDScript] itself.
var reference_object: Object
var _callbacks: Array[Callable] = []
var _callback_index := -1
const LOG_NAME := "ModLoaderHookChain"
# `callbacks` is kept as untyped Array for simplicity when creating a new chain.
# This approach allows direct use of `[vanilla_method] + hooks` without the need to cast types with Array.assign().
func _init(reference_object: Object, callbacks: Array) -> void:
self.reference_object = reference_object
_callbacks.assign(callbacks)
_callback_index = callbacks.size()
## Will execute the next mod hook callable or vanilla method and return the result.[br]
## [br]
## [br][b]Parameters:[/b][br]
## - [param args] ([Array]): An array of all arguments passed into the vanilla function. [br]
## [br]
## [br][b]Returns:[/b][br]
## - [Variant]: Return value of the next function in the chain.[br]
## [br]
## Make sure to call this method [i][color=orange]once[/color][/i] somewhere in the [param mod_callable] you pass to [method ModLoaderMod.add_hook]. [br]
func execute_next(args := []) -> Variant:
var callback := _get_next_callback()
if not callback:
return
# Vanilla needs to be called without the hook chain being passed
if _is_callback_vanilla():
return callback.callv(args)
return callback.callv([self] + args)
## Same as [method execute_next], but asynchronous - it can be used if a method uses [code]await[/code]. [br]
## [br]
## [br][b]Parameters:[/b][br]
## - [param args] ([Array]): An array of all arguments passed into the vanilla function. [br]
## [br]
## [br][b]Returns:[/b][br]
## - [Variant]: Return value of the next function in the chain.[br]
## [br]
## This hook needs to be used if the vanilla method uses [code]await[/code] somewhere. [br]
## Make sure to call this method [i][color=orange]once[/color][/i] somewhere in the [param mod_callable] you pass to [method ModLoaderMod.add_hook]. [br]
func execute_next_async(args := []) -> Variant:
var callback := _get_next_callback()
if not callback:
return
# Vanilla needs to be called without the hook chain being passed
if _is_callback_vanilla():
return await callback.callv(args)
return await callback.callv([self] + args)
func _get_next_callback() -> Variant:
_callback_index -= 1
if not _callback_index >= 0:
ModLoaderLog.fatal(
"The hook chain index should never be negative. " +
"A mod hook has called execute_next twice or ModLoaderHookChain was modified in an unsupported way.",
LOG_NAME
)
return
return _callbacks[_callback_index]
func _is_callback_vanilla() -> bool:
return _callback_index == 0

View File

@@ -0,0 +1 @@
uid://nyep44jvp7yc

View File

@@ -0,0 +1,599 @@
@tool
class_name ModLoaderLog
extends Object
##
## This Class provides methods for logging, retrieving logged data, and internal methods for working with log files.
# Path to the latest log file.
const MOD_LOG_PATH := "user://logs/modloader.log"
const _LOG_NAME := "ModLoader:Log"
## Denotes the severity of a log entry
enum VERBOSITY_LEVEL {
ERROR, ## For errors and fatal errors
WARNING, ## For warnings
INFO, ## For everything informational and successes
DEBUG, ## For debugging, can get quite verbose
}
## Keeps track of logged messages, to avoid flooding the log with duplicate notices
## Can also be used by mods, eg. to create an in-game developer console that
## shows messages
static var logged_messages := {
"all": {},
"by_mod": {},
"by_type": {
"fatal-error": {},
"error": {},
"warning": {},
"info": {},
"success": {},
"debug": {},
"hint": {},
}
}
## Verbosity/Logging level.
## Used to filter out messages below the set level
## (if the [enum VERBOSITY_LEVEL] int of a new entry is larger than the [member verbosity] it is ignored)
static var verbosity: VERBOSITY_LEVEL = VERBOSITY_LEVEL.DEBUG
## Array of mods that should be ignored when logging messages (contains mod IDs as strings)
static var ignored_mods: Array[String] = []
## Highlighting color for hint type log messages
static var hint_color := Color("#70bafa")
## This Sub-Class represents a log entry in ModLoader.
class ModLoaderLogEntry:
extends Resource
## Name of the mod or ModLoader class this entry refers to.
var mod_name: String
## The message of the log entry.
var message: String
## The log type, which indicates the verbosity level of this entry.
var type: String
## The readable format of the time when this log entry was created.
## Used for printing in the log file and output.
var time: String
## The timestamp when this log entry was created.
## Used for comparing and sorting log entries by time.
var time_stamp: int
## An array of ModLoaderLogEntry objects.
## If the message has been logged before, it is added to the stack.
var stack := []
## Initialize a ModLoaderLogEntry object with provided values.[br]
##[br]
## [b]Parameters:[/b][br]
## [param _mod_name] ([String]): Name of the mod or ModLoader class this entry refers to.[br]
## [param _message] ([String]): The message of the log entry.[br]
## [param _type] ([String]): The log type, which indicates the verbosity level of this entry.[br]
## [param _time] ([String]): The readable format of the time when this log entry was created.[br]
##[br]
## [b]Returns:[/b][br]
## - No return value[br]
func _init(_mod_name: String, _message: String, _type: String, _time: String) -> void:
mod_name = _mod_name
message = _message
type = _type
time = _time
time_stamp = Time.get_ticks_msec()
## Get the log entry as a formatted string.[br]
## [br]
## [b]Returns:[/b] [String]
func get_entry() -> String:
return str(time, get_prefix(), message)
## Get the prefix string for the log entry, including the log type and mod name.[br]
## [br]
## [b]Returns:[/b] [String]
func get_prefix() -> String:
return "%s %s: " % [type.to_upper(), mod_name]
## Generate an MD5 hash of the log entry (prefix + message).[br]
## [br]
## [b]Returns:[/b] [String]
func get_md5() -> String:
return str(get_prefix(), message).md5_text()
## Get all log entries, including the current entry and entries in the stack.[br]
## [br]
## [b]Returns:[/b] [Array]
func get_all_entries() -> Array:
var entries := [self]
entries.append_array(stack)
return entries
# API log functions - logging
# =============================================================================
## Logs the error in red and a stack trace. Prefixed FATAL-ERROR.[br]
## Always logged.[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as an error.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## ===[br]
## [b]Note:[color=bug "Breakpoint"][/color][/b][br]
## Stops execution in the editor, use this when something really needs to be fixed.[br]
## ===[br]
static func fatal(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "fatal-error", only_once)
## Logs the message and pushes an error. Prefixed ERROR.[br]
## Always logged.[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as an error.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func error(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "error", only_once)
## Logs the message and pushes a warning. Prefixed WARNING.[br]
## Logged with verbosity level at or above warning ([code]-v[/code] or [code]--log-warning[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a warning.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func warning(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "warning", only_once)
## Logs the message. Prefixed INFO.[br]
## Logged with verbosity level at or above info ([code]-vv[/code] or [code]--log-info[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as an information.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func info(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "info", only_once)
## Logs the message. Prefixed SUCCESS.[br]
## Logged with verbosity level at or above info ([code]-vv[/code] or [code]--log-info[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a success.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func success(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "success", only_once)
## Logs the message. Prefixed DEBUG.[br]
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a debug.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func debug(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "debug", only_once)
## Logs the message. Prefixed HINT and highligted.[br]
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]) and in the editor only. Not written to mod loader log.[br]
## ===[br]
## [b]Note:[/b][br]
## Use this to help other developers debug issues by giving them error-specific hints.[br]
## ===[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a debug.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func hint(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "hint", only_once)
## Logs the message formatted with [method JSON.print]. Prefixed DEBUG.[br]
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a debug.[br]
## [param json_printable] (Variant): The variable to be formatted and printed using [method JSON.print].[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func debug_json_print(message: String, json_printable, mod_name: String, only_once := false) -> void:
message = "%s\n%s" % [message, JSON.stringify(json_printable, " ")]
_log(message, mod_name, "debug", only_once)
# API log functions - stored logs
# =============================================================================
## Returns an array of log entries as a resource.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as resource.
static func get_all_as_resource() -> Array:
return get_all()
## Returns an array of log entries as a string.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as strings.
static func get_all_as_string() -> Array:
var log_entries := get_all()
return get_all_entries_as_string(log_entries)
## Returns an array of log entries as a resource for a specific mod_name.[br]
## [br]
## [b]Parameters:[/b][br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as resource for the specified [code]mod_name[/code].
static func get_by_mod_as_resource(mod_name: String) -> Array:
return get_by_mod(mod_name)
## Returns an array of log entries as a string for a specific mod_name.[br]
## [br]
## [b]Parameters:[/b][br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as strings for the specified [code]mod_name[/code].
static func get_by_mod_as_string(mod_name: String) -> Array:
var log_entries := get_by_mod(mod_name)
return get_all_entries_as_string(log_entries)
## Returns an array of log entries as a resource for a specific type.[br]
## [br]
## [b]Parameters:[/b][br]
## [param type] ([String]): The log type associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as resource for the specified [code]type[/code].
static func get_by_type_as_resource(type: String) -> Array:
return get_by_type(type)
## Returns an array of log entries as a string for a specific type.[br]
## [br]
## [b]Parameters:[/b][br]
## [param type] ([String]): The log type associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as strings for the specified [code]type[/code].
static func get_by_type_as_string(type: String) -> Array:
var log_entries := get_by_type(type)
return get_all_entries_as_string(log_entries)
## Returns an array of all log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of all log entries.
static func get_all() -> Array:
var log_entries := []
# Get all log entries
for entry_key in logged_messages.all.keys():
var entry: ModLoaderLogEntry = logged_messages.all[entry_key]
log_entries.append_array(entry.get_all_entries())
# Sort them by time
log_entries.sort_custom(Callable(ModLoaderLogCompare, "time"))
return log_entries
## Returns an array of log entries for a specific mod_name.[br]
## [br]
## [b]Parameters:[/b][br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries for the specified [code]mod_name[/code].
static func get_by_mod(mod_name: String) -> Array:
var log_entries := []
if not logged_messages.by_mod.has(mod_name):
error("\"%s\" not found in logged messages." % mod_name, _LOG_NAME)
return []
for entry_key in logged_messages.by_mod[mod_name].keys():
var entry: ModLoaderLogEntry = logged_messages.by_mod[mod_name][entry_key]
log_entries.append_array(entry.get_all_entries())
return log_entries
## Returns an array of log entries for a specific type.[br]
## [br]
## [b]Parameters:[/b][br]
## [param type] ([String]): The log type associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries for the specified [code]type[/code].
static func get_by_type(type: String) -> Array:
var log_entries := []
for entry_key in logged_messages.by_type[type].keys():
var entry: ModLoaderLogEntry = logged_messages.by_type[type][entry_key]
log_entries.append_array(entry.get_all_entries())
return log_entries
## Returns an array of log entries represented as strings.[br]
## [br]
## [b]Parameters:[/b][br]
## [param log_entries] ([Array]): An array of ModLoaderLogEntry Objects.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as strings.
static func get_all_entries_as_string(log_entries: Array) -> Array:
var log_entry_strings := []
# Get all the strings
for entry in log_entries:
log_entry_strings.push_back(entry.get_entry())
return log_entry_strings
# Internal log functions
# =============================================================================
static func _log(message: String, mod_name: String, log_type: String = "info", only_once := false) -> void:
if _is_mod_name_ignored(mod_name):
return
var time := "%s " % _get_time_string()
var log_entry := ModLoaderLogEntry.new(mod_name, message, log_type, time)
if only_once and _is_logged_before(log_entry):
return
_store_log(log_entry)
# Check if the scene_tree is available
if Engine.get_main_loop() and ModLoader:
ModLoader.emit_signal("logged", log_entry)
_code_note(str(
"If you are seeing this after trying to run the game, there is an error in your mod somewhere.",
"Check the Debugger tab (below) to see the error.",
"Click through the files listed in Stack Frames to trace where the error originated.",
"View Godot's documentation for more info:",
"https://docs.godotengine.org/en/stable/tutorials/scripting/debug/debugger_panel.html#doc-debugger-panel"
))
match log_type.to_lower():
"fatal-error":
push_error(message)
_write_to_log_file(log_entry.get_entry())
_write_to_log_file(JSON.stringify(get_stack(), " "))
assert(false, message)
"error":
printerr(log_entry.get_prefix() + message)
push_error(message)
_write_to_log_file(log_entry.get_entry())
"warning":
if verbosity >= VERBOSITY_LEVEL.WARNING:
print(log_entry.get_prefix() + message)
push_warning(message)
_write_to_log_file(log_entry.get_entry())
"info", "success":
if verbosity >= VERBOSITY_LEVEL.INFO:
print(log_entry.get_prefix() + message)
_write_to_log_file(log_entry.get_entry())
"debug":
if verbosity >= VERBOSITY_LEVEL.DEBUG:
print(log_entry.get_prefix() + message)
_write_to_log_file(log_entry.get_entry())
"hint":
if OS.has_feature("editor") and verbosity >= VERBOSITY_LEVEL.DEBUG:
print_rich("[color=%s]%s[/color]" % [hint_color.to_html(false), log_entry.get_prefix() + message])
static func _is_mod_name_ignored(mod_name: String) -> bool:
if ignored_mods.is_empty():
return false
if mod_name in ignored_mods:
return true
return false
static func _store_log(log_entry: ModLoaderLogEntry) -> void:
var existing_entry: ModLoaderLogEntry
# Store in all
# If it's a new entry
if not logged_messages.all.has(log_entry.get_md5()):
logged_messages.all[log_entry.get_md5()] = log_entry
# If it's a existing entry
else:
existing_entry = logged_messages.all[log_entry.get_md5()]
existing_entry.time = log_entry.time
existing_entry.stack.push_back(log_entry)
# Store in by_mod
# If the mod is not yet in "by_mod" init the entry
if not logged_messages.by_mod.has(log_entry.mod_name):
logged_messages.by_mod[log_entry.mod_name] = {}
logged_messages.by_mod[log_entry.mod_name][log_entry.get_md5()] = log_entry if not existing_entry else existing_entry
# Store in by_type
logged_messages.by_type[log_entry.type.to_lower()][log_entry.get_md5()] = log_entry if not existing_entry else existing_entry
static func _is_logged_before(entry: ModLoaderLogEntry) -> bool:
if not logged_messages.all.has(entry.get_md5()):
return false
return true
class ModLoaderLogCompare:
# Custom sorter that orders logs by time
static func time(a: ModLoaderLogEntry, b: ModLoaderLogEntry) -> bool:
if a.time_stamp > b.time_stamp:
return true # a -> b
else:
return false # b -> a
# Internal Date Time
# =============================================================================
# Returns the current time as a string in the format hh:mm:ss
static func _get_time_string() -> String:
var date_time := Time.get_datetime_dict_from_system()
return "%02d:%02d:%02d" % [ date_time.hour, date_time.minute, date_time.second ]
# Returns the current date as a string in the format yyyy-mm-dd
static func _get_date_string() -> String:
var date_time := Time.get_datetime_dict_from_system()
return "%s-%02d-%02d" % [ date_time.year, date_time.month, date_time.day ]
# Returns the current date and time as a string in the format yyyy-mm-dd_hh:mm:ss
static func _get_date_time_string() -> String:
return "%s_%s" % [ _get_date_string(), _get_time_string() ]
# Internal File
# =============================================================================
static func _write_to_log_file(string_to_write: String) -> void:
if not FileAccess.file_exists(MOD_LOG_PATH):
_rotate_log_file()
var log_file := FileAccess.open(MOD_LOG_PATH, FileAccess.READ_WRITE)
if log_file == null:
assert(false, "Could not open log file, error code: %s" % error)
return
log_file.seek_end()
log_file.store_string("\n" + string_to_write)
log_file.close()
# Keeps log backups for every run, just like the Godot gdscript implementation of
# https://github.com/godotengine/godot/blob/1d14c054a12dacdc193b589e4afb0ef319ee2aae/core/io/logger.cpp#L151
static func _rotate_log_file() -> void:
var MAX_LOGS: int = ProjectSettings.get_setting("debug/file_logging/max_log_files")
if FileAccess.file_exists(MOD_LOG_PATH):
if MAX_LOGS > 1:
var datetime := _get_date_time_string().replace(":", ".")
var backup_name: String = MOD_LOG_PATH.get_basename() + "_" + datetime
if MOD_LOG_PATH.get_extension().length() > 0:
backup_name += "." + MOD_LOG_PATH.get_extension()
var dir := DirAccess.open(MOD_LOG_PATH.get_base_dir())
if not dir == null:
dir.copy(MOD_LOG_PATH, backup_name)
_clear_old_log_backups()
# only File.WRITE creates a new file, File.READ_WRITE throws an error
var log_file := FileAccess.open(MOD_LOG_PATH, FileAccess.WRITE)
if log_file == null:
assert(false, "Could not open log file, error code: %s" % error)
log_file.store_string('%s Created log' % _get_date_string())
log_file.close()
static func _clear_old_log_backups() -> void:
var MAX_LOGS := int(ProjectSettings.get_setting("debug/file_logging/max_log_files"))
var MAX_BACKUPS := MAX_LOGS - 1 # -1 for the current new log (not a backup)
var basename := MOD_LOG_PATH.get_file().get_basename() as String
var extension := MOD_LOG_PATH.get_extension() as String
var dir := DirAccess.open(MOD_LOG_PATH.get_base_dir())
if dir == null:
return
dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
var file := dir.get_next()
var backups := []
while file.length() > 0:
if (not dir.current_is_dir() and
file.begins_with(basename) and
file.get_extension() == extension and
not file == MOD_LOG_PATH.get_file()):
backups.append(file)
file = dir.get_next()
dir.list_dir_end()
if backups.size() > MAX_BACKUPS:
backups.sort()
backups.resize(backups.size() - MAX_BACKUPS)
for file_to_delete in backups:
dir.remove(file_to_delete)
# Internal util funcs
# =============================================================================
# This are duplicates of the functions in mod_loader_utils.gd to prevent
# a cyclic reference error between ModLoaderLog and ModLoaderUtils.
# This is a dummy func. It is exclusively used to show notes in the code that
# stay visible after decompiling a PCK, as is primarily intended to assist new
# modders in understanding and troubleshooting issues.
static func _code_note(_msg:String):
pass

View File

@@ -0,0 +1 @@
uid://dfoleo2pforxu

View File

@@ -0,0 +1,358 @@
class_name ModLoaderMod
extends Object
##
## This Class provides helper functions to build mods.
##
## @tutorial(Script Extensions): https://wiki.godotmodding.com/#/guides/modding/script_extensions
## @tutorial(Script Hooks): https://wiki.godotmodding.com/#/guides/modding/script_hooks
## @tutorial(Mod Structure): https://wiki.godotmodding.com/#/guides/modding/mod_structure
## @tutorial(Mod Files): https://wiki.godotmodding.com/#/guides/modding/mod_files
const LOG_NAME := "ModLoader:Mod"
## Installs a script extension that extends a vanilla script.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param child_script_path] ([String]): The path to the mod's extender script.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## This is the preferred way of modifying a vanilla [Script][br]
## Since Godot 4, extensions can cause issues with scripts that use [code]class_name[/code]
## and should be avoided if present.[br]
## See [method add_hook] for those cases.[br]
## [br]
## The [param child_script_path] should point to your mod's extender script.[br]
## Example: [code]"MOD/extensions/singletons/utils.gd"[/code][br]
## Inside the extender script, include [code]extends {target}[/code] where [code]{target}[/code] is the vanilla path.[br]
## Example: [code]extends "res://singletons/utils.gd"[/code].[br]
## ===[br]
## [b]Note:[/b][br]
## Your extender script doesn't have to follow the same directory path as the vanilla file,
## but it's good practice to do so.[br]
## ===[br]
## [br]
static func install_script_extension(child_script_path: String) -> void:
var mod_id: String = _ModLoaderPath.get_mod_dir(child_script_path)
var mod_data: ModData = get_mod_data(mod_id)
if not ModLoaderStore.saved_extension_paths.has(mod_data.manifest.get_mod_id()):
ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()] = []
ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()].append(child_script_path)
# If this is called during initialization, add it with the other
# extensions to be installed taking inheritance chain into account
if ModLoaderStore.is_initializing:
ModLoaderStore.script_extensions.push_back(child_script_path)
# If not, apply the extension directly
else:
_ModLoaderScriptExtension.apply_extension(child_script_path)
## Adds all methods from a file as hooks. [br]
## [br]
## [b]Parameters:[/b][br]
## - [param vanilla_script_path] ([String]): The path to the script which will be hooked.[br]
## - [param hook_script_path] ([String]): The path to the script containing hooks.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## The file needs to extend [Object].[br]
## The methods in the file need to have the exact same name as the vanilla method
## they intend to hook, all mismatches will be ignored. [br]
## See: [method add_hook]
## [br]
## [b]Examples:[/b][br]
## [codeblock]
## ModLoaderMod.install_script_hooks(
## "res://tools/utilities.gd",
## extensions_dir_path.path_join("tools/utilities-hook.gd")
## )
## [/codeblock]
static func install_script_hooks(vanilla_script_path: String, hook_script_path: String) -> void:
var hook_script := load(hook_script_path) as GDScript
var hook_script_instance := hook_script.new()
# Every script that inherits RefCounted will be cleaned up by the engine as
# soon as there are no more references to it. If the reference is gone
# the method can't be called and everything returns null.
# Only Object won't be removed, so we can use it here.
if hook_script_instance is RefCounted:
ModLoaderLog.fatal(
"Scripts holding mod hooks should always extend Object (%s)"
% hook_script_path, LOG_NAME
)
var vanilla_script := load(vanilla_script_path) as GDScript
var vanilla_methods := vanilla_script.get_script_method_list().map(
func(method: Dictionary) -> String:
return method.name
)
var methods := hook_script.get_script_method_list()
for hook in methods:
if hook.name in vanilla_methods:
ModLoaderMod.add_hook(Callable(hook_script_instance, hook.name), vanilla_script_path, hook.name)
continue
ModLoaderLog.debug(
'Skipped adding hook "%s" (not found in vanilla script %s)'
% [hook.name, vanilla_script_path], LOG_NAME
)
if not OS.has_feature("editor"):
continue
vanilla_methods.sort_custom((
func(a_name: String, b_name: String, target_name: String) -> bool:
return a_name.similarity(target_name) > b_name.similarity(target_name)
).bind(hook.name))
var closest_vanilla: String = vanilla_methods.front()
if closest_vanilla.similarity(hook.name) > 0.8:
ModLoaderLog.hint(
'Did you mean "%s" instead of "%s"?'
% [closest_vanilla, hook.name], LOG_NAME
)
## Adds a hook, a custom mod function, to a vanilla method.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_callable] ([Callable]): The function that will executed when
## the vanilla method is executed. When writing a mod callable, make sure
## that it [i]always[/i] receives a [ModLoaderHookChain] object as first argument,
## which is used to continue down the hook chain (see: [method ModLoaderHookChain.execute_next])
## and allows manipulating parameters before and return values after the
## vanilla method is called. [br]
## - [param script_path] ([String]): Path to the vanilla script that holds the method.[br]
## - [param method_name] ([String]): The method the hook will be applied to.[br]
## [br]
## [b]Returns:[/b][br][br]
## - No return value[br]
## [br]
## Opposed to script extensions, hooks can be applied to scripts that use
## [code]class_name[/code] without issues.[br]
## If possible, prefer [method install_script_extension].[br]
## [br]
## [b]Examples:[/b][br]
## [br]
## Given the following vanilla script [code]main.gd[/code]
## [codeblock]
## class_name MainGame
## extends Node2D
##
## var version := "vanilla 1.0.0"
##
##
## func _ready():
## $CanvasLayer/Control/Label.text = "Version: %s" % version
## print(Utilities.format_date(15, 11, 2024))
## [/codeblock]
##
## It can be hooked in [code]mod_main.gd[/code] like this
## [codeblock]
## func _init() -> void:
## ModLoaderMod.add_hook(change_version, "res://main.gd", "_ready")
## ModLoaderMod.add_hook(time_travel, "res://tools/utilities.gd", "format_date")
## # Multiple hooks can be added to a single method.
## ModLoaderMod.add_hook(add_season, "res://tools/utilities.gd", "format_date")
##
##
## # The script we are hooking is attached to a node, which we can get from reference_object
## # then we can change any variables it has
## func change_version(chain: ModLoaderHookChain) -> void:
## # Using a typecast here (with "as") can help with autocomplete and avoiding errors
## var main_node := chain.reference_object as MainGame
## main_node.version = "Modloader Hooked!"
## # _ready, which we are hooking, does not have any arguments
## chain.execute_next()
##
##
## # Parameters can be manipulated easily by changing what is passed into .execute_next()
## # The vanilla method (Utilities.format_date) takes 3 arguments, our hook method takes
## # the ModLoaderHookChain followed by the same 3
## func time_travel(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
## print("time travel!")
## year -= 100
## # Just the vanilla arguments are passed along in the same order, wrapped into an Array
## var val = chain.execute_next([day, month, year])
## return val
##
##
## # The return value can be manipulated by calling the next hook (or vanilla) first
## # then changing it and returning the new value.
## func add_season(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
## var output = chain.execute_next([day, month, year])
## match month:
## 12, 1, 2:
## output += ", Winter"
## 3, 4, 5:
## output += ", Spring"
## 6, 7, 8:
## output += ", Summer"
## 9, 10, 11:
## output += ", Autumn"
## return output
## [/codeblock]
##
static func add_hook(mod_callable: Callable, script_path: String, method_name: String) -> void:
_ModLoaderHooks.add_hook(mod_callable, script_path, method_name)
## Registers an array of classes to the global scope since Godot only does that in the editor.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param new_global_classes] ([Array]): An array of class definitions to be registered.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## Format: [code]{ "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" }[/code][br]
## [br]
## ===[br]
## [b]Tip:[/b][color=tip][/color][br]
## You can find these easily in the project.godot file under `_global_script_classes`[br]
## (but you should only include classes belonging to your mod)[br]
## ===[br]
static func register_global_classes_from_array(new_global_classes: Array) -> void:
ModLoaderUtils.register_global_classes_from_array(new_global_classes)
var _savecustom_error: int = ProjectSettings.save_custom(_ModLoaderPath.get_override_path())
## Adds a translation file.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param resource_path] ([String]): The path to the translation resource file.[br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## ===[br]
## [b]Note:[/b][br]
## The [code].translation[/code] file should have been created by the Godot editor already, usually when importing a CSV file.
## The translation file should named [code]name.langcode.translation[/code] -> [code]mytranslation.en.translation[/code].[br]
## ===[br]
static func add_translation(resource_path: String) -> void:
if not _ModLoaderFile.file_exists(resource_path):
ModLoaderLog.fatal("Tried to load a position resource from a file that doesn't exist. The invalid path was: %s" % [resource_path], LOG_NAME)
return
var translation_object: Translation = load(resource_path)
if translation_object:
TranslationServer.add_translation(translation_object)
ModLoaderLog.info("Added Translation from Resource -> %s" % resource_path, LOG_NAME)
else:
ModLoaderLog.fatal("Failed to load translation at path: %s" % [resource_path], LOG_NAME)
## Marks the given scene for to be refreshed. It will be refreshed at the correct point in time later.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param scene_path] ([String]): The path to the scene file to be refreshed.
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## ===[br]
## [b]Note:[/b][color=abstract "Version"][/color][br]
## This function requires Godot 4.3 or higher.[br]
## ===[br]
## [br]
## This function is useful if a script extension is not automatically applied.
## This situation can occur when a script is attached to a preloaded scene.
## If you encounter issues where your script extension is not working as expected,
## try to identify the scene to which it is attached and use this method to refresh it.
## This will reload already loaded scenes and apply the script extension.
## [br]
static func refresh_scene(scene_path: String) -> void:
if scene_path in ModLoaderStore.scenes_to_refresh:
return
ModLoaderStore.scenes_to_refresh.push_back(scene_path)
ModLoaderLog.debug("Added \"%s\" to be refreshed." % scene_path, LOG_NAME)
## Extends a specific scene by providing a callable function to modify it.
## [br]
## [b]Parameters:[/b][br]
## - [param scene_vanilla_path] ([String]): The path to the vanilla scene file.[br]
## - [param edit_callable] ([Callable]): The callable function to modify the scene.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## The callable receives an instance of the "vanilla_scene" as the first parameter.[br]
static func extend_scene(scene_vanilla_path: String, edit_callable: Callable) -> void:
if not ModLoaderStore.scenes_to_modify.has(scene_vanilla_path):
ModLoaderStore.scenes_to_modify[scene_vanilla_path] = []
ModLoaderStore.scenes_to_modify[scene_vanilla_path].push_back(edit_callable)
## Gets the [ModData] from the provided namespace.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModData]: The [ModData] associated with the provided [code]mod_id[/code], or null if the [code]mod_id[/code] is invalid.[br]
static func get_mod_data(mod_id: String) -> ModData:
if not ModLoaderStore.mod_data.has(mod_id):
ModLoaderLog.error("%s is an invalid mod_id" % mod_id, LOG_NAME)
return null
return ModLoaderStore.mod_data[mod_id]
## Gets the [ModData] of all loaded Mods as [Dictionary].[br]
## [br]
## [b]Returns:[/b][br]
## - [Dictionary]: A dictionary containing the [ModData] of all loaded mods.[br]
static func get_mod_data_all() -> Dictionary:
return ModLoaderStore.mod_data
## Returns the path to the directory where unpacked mods are stored.[br]
## [br]
## [b]Returns:[/b][br]
## - [String]: The path to the unpacked mods directory.[br]
static func get_unpacked_dir() -> String:
return _ModLoaderPath.get_unpacked_mods_dir_path()
## Returns true if the mod with the given [code]mod_id[/code] was successfully loaded.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: true if the mod is loaded, false otherwise.[br]
static func is_mod_loaded(mod_id: String) -> bool:
if ModLoaderStore.is_initializing:
ModLoaderLog.warning(
"The ModLoader is not fully initialized. " +
"Calling \"is_mod_loaded()\" in \"_init()\" may result in an unexpected return value as mods are still loading.",
LOG_NAME
)
# If the mod is not present in the mod_data dictionary or the mod is flagged as not loadable.
if not ModLoaderStore.mod_data.has(mod_id) or not ModLoaderStore.mod_data[mod_id].is_loadable:
return false
return true
## Returns true if the mod with the given mod_id was successfully loaded and is currently active.
## [br]
## Parameters:
## - [param mod_id] ([String]): The ID of the mod.
## [br]
## Returns:
## - [bool]: true if the mod is loaded and active, false otherwise.
static func is_mod_active(mod_id: String) -> bool:
return is_mod_loaded(mod_id) and ModLoaderStore.mod_data[mod_id].is_active

View File

@@ -0,0 +1 @@
uid://d2hugw88f3q4e

View File

@@ -0,0 +1,475 @@
class_name ModLoaderUserProfile
extends Object
##
## This Class provides methods for working with user profiles.
const LOG_NAME := "ModLoader:UserProfile"
# The path where the Mod User Profiles data is stored.
const FILE_PATH_USER_PROFILES := "user://mod_user_profiles.json"
# API profile functions
# =============================================================================
## Enables a mod - it will be loaded on the next game start[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod to enable.[br]
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to enable the mod for. Default is the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func enable_mod(mod_id: String, user_profile:= ModLoaderStore.current_user_profile) -> bool:
return _set_mod_state(mod_id, user_profile.name, true)
## Forces a mod to enable, ensuring it loads at the next game start, regardless of load warnings.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod to enable.[br]
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile for which the mod will be enabled. Defaults to the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func force_enable_mod(mod_id: String, user_profile:= ModLoaderStore.current_user_profile) -> bool:
return _set_mod_state(mod_id, user_profile.name, true, true)
## Disables a mod - it will not be loaded on the next game start[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod to disable.[br]
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to disable the mod for. Default is the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func disable_mod(mod_id: String, user_profile := ModLoaderStore.current_user_profile) -> bool:
return _set_mod_state(mod_id, user_profile.name, false)
## Sets the current config for a mod in a user profile's mod_list.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## - [param mod_config] ([ModConfig]): The mod config to set as the current config.[br]
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to update. Default is the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func set_mod_current_config(mod_id: String, mod_config: ModConfig, user_profile := ModLoaderStore.current_user_profile) -> bool:
# Verify whether the mod_id is present in the profile's mod_list.
if not _is_mod_id_in_mod_list(mod_id, user_profile.name):
return false
# Update the current config in the mod_list of the user profile
user_profile.mod_list[mod_id].current_config = mod_config.name
# Store the new profile in the json file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Set the \"current_config\" of \"%s\" to \"%s\" in user profile \"%s\" " % [mod_id, mod_config.name, user_profile.name], LOG_NAME)
return is_save_success
## Creates a new user profile with the given name, using the currently loaded mods as the mod list.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param profile_name] ([String]): The name of the new user profile (must be unique).[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func create_profile(profile_name: String) -> bool:
# Verify that the profile name is not already in use
if ModLoaderStore.user_profiles.has(profile_name):
ModLoaderLog.error("User profile with the name of \"%s\" already exists." % profile_name, LOG_NAME)
return false
var mod_list := _generate_mod_list()
var new_profile := _create_new_profile(profile_name, mod_list)
# If there was an error creating the new user profile return
if not new_profile:
return false
# Store the new profile in the ModLoaderStore
ModLoaderStore.user_profiles[profile_name] = new_profile
# Set it as the current profile
ModLoaderStore.current_user_profile = new_profile
# Store the new profile in the json file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Created new user profile \"%s\"" % profile_name, LOG_NAME)
return is_save_success
## Sets the current user profile to the given user profile.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param user_profile] ([ModUserProfile]): The user profile to set as the current profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func set_profile(user_profile: ModUserProfile) -> bool:
# Check if the profile name is unique
if not ModLoaderStore.user_profiles.has(user_profile.name):
ModLoaderLog.error("User profile with name \"%s\" not found." % user_profile.name, LOG_NAME)
return false
# Update the current_user_profile in the ModLoaderStore
ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[user_profile.name]
# Save changes in the json file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Current user profile set to \"%s\"" % user_profile.name, LOG_NAME)
return is_save_success
## Deletes the given user profile.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param user_profile] ([ModUserProfile]): The user profile to delete.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func delete_profile(user_profile: ModUserProfile) -> bool:
# If the current_profile is about to get deleted log an error
if ModLoaderStore.current_user_profile.name == user_profile.name:
ModLoaderLog.error(str(
"You cannot delete the currently selected user profile \"%s\" " +
"because it is currently in use. Please switch to a different profile before deleting this one.") % user_profile.name,
LOG_NAME)
return false
# Deleting the default profile is not allowed
if user_profile.name == "default":
ModLoaderLog.error("You can't delete the default profile", LOG_NAME)
return false
# Delete the user profile
if not ModLoaderStore.user_profiles.erase(user_profile.name):
# Erase returns false if the the key is not present in user_profiles
ModLoaderLog.error("User profile with name \"%s\" not found." % user_profile.name, LOG_NAME)
return false
# Save profiles to the user profiles JSON file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Deleted user profile \"%s\"" % user_profile.name, LOG_NAME)
return is_save_success
## Returns the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModUserProfile]: The current profile or [code]null[/code] if not set.
static func get_current() -> ModUserProfile:
return ModLoaderStore.current_user_profile
## Returns the user profile with the given name.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param profile_name] ([String]): The name of the user profile to retrieve.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModUserProfile]: The profile or [code]null[/code] if not found
static func get_profile(profile_name: String) -> ModUserProfile:
if not ModLoaderStore.user_profiles.has(profile_name):
ModLoaderLog.error("User profile with name \"%s\" not found." % profile_name, LOG_NAME)
return null
return ModLoaderStore.user_profiles[profile_name]
## Returns an array containing all user profiles stored in ModLoaderStore.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: A list of [ModUserProfile] Objects
static func get_all_as_array() -> Array:
var user_profiles := []
for user_profile_name in ModLoaderStore.user_profiles.keys():
user_profiles.push_back(ModLoaderStore.user_profiles[user_profile_name])
return user_profiles
## Returns true if the Mod User Profiles are initialized.
## [br]
## [b]Returns:[/b][br]
## - [bool]: True if profiles are ready.
## [br]
## On the first execution of the game, user profiles might not yet be created.
## Use this method to check if everything is ready to interact with the ModLoaderUserProfile API.
static func is_initialized() -> bool:
return _ModLoaderFile.file_exists(FILE_PATH_USER_PROFILES)
# Internal profile functions
# =============================================================================
# Update the global list of disabled mods based on the current user profile
# The user profile will override the disabled_mods property that can be set via the options resource in the editor.
# Example: If "Mod-TestMod" is set in disabled_mods via the editor, the mod will appear disabled in the user profile.
# If the user then enables the mod in the profile the entry in disabled_mods will be removed.
static func _update_disabled_mods() -> void:
var current_user_profile: ModUserProfile = get_current()
# Check if a current user profile is set
if not current_user_profile:
ModLoaderLog.info("There is no current user profile. The \"default\" profile will be created.", LOG_NAME)
return
# Iterate through the mod list in the current user profile to find disabled mods
for mod_id in current_user_profile.mod_list:
var mod_list_entry: Dictionary = current_user_profile.mod_list[mod_id]
if ModLoaderStore.mod_data.has(mod_id):
ModLoaderStore.mod_data[mod_id].set_mod_state(mod_list_entry.is_active, true)
ModLoaderLog.debug(
"Updated the active state of all mods, based on the current user profile \"%s\""
% current_user_profile.name,
LOG_NAME)
# This function updates the mod lists of all user profiles with newly loaded mods that are not already present.
# It does so by comparing the current set of loaded mods with the mod list of each user profile, and adding any missing mods.
# Additionally, it checks for and deletes any mods from each profile's mod list that are no longer installed on the system.
static func _update_mod_lists() -> bool:
# Generate a list of currently present mods by combining the mods
# in mod_data and ml_options.disabled_mods from ModLoaderStore.
var current_mod_list := _generate_mod_list()
# Iterate over all user profiles
for profile_name in ModLoaderStore.user_profiles.keys():
var profile: ModUserProfile = ModLoaderStore.user_profiles[profile_name]
# Merge the profiles mod_list with the previously created current_mod_list
profile.mod_list.merge(current_mod_list)
var update_mod_list := _update_mod_list(profile.mod_list)
profile.mod_list = update_mod_list
# Save the updated user profiles to the JSON file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Updated the mod lists of all user profiles", LOG_NAME)
return is_save_success
# This function takes a mod_list dictionary and optional mod_data dictionary as input and returns
# an updated mod_list dictionary. It iterates over each mod ID in the mod list, checks if the mod
# is still installed and if the current_config is present. If the mod is not installed or the current
# config is missing, the mod is removed or its current_config is reset to the default configuration.
static func _update_mod_list(mod_list: Dictionary, mod_data := ModLoaderStore.mod_data) -> Dictionary:
var updated_mod_list := mod_list.duplicate(true)
# Iterate over each mod ID in the mod list
for mod_id in updated_mod_list.keys():
var mod_list_entry: Dictionary = updated_mod_list[mod_id]
# Check if the current config doesn't exist
# This can happen if the config file was manually deleted
if mod_list_entry.has("current_config") and _ModLoaderPath.get_path_to_mod_config_file(mod_id, mod_list_entry.current_config).is_empty():
# If the current config doesn't exist, reset it to the default configuration
mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME
if (
# If the mod is not loaded
not mod_data.has(mod_id) and
# Check if the entry has a zip_path key
mod_list_entry.has("zip_path") and
# Check if the entry has a zip_path
not mod_list_entry.zip_path.is_empty() and
# Check if the zip file for the mod doesn't exist
not _ModLoaderFile.file_exists(mod_list_entry.zip_path)
):
# If the mod directory doesn't exist,
# the mod is no longer installed and can be removed from the mod list
ModLoaderLog.debug(
"Mod \"%s\" has been deleted from all user profiles as the corresponding zip file no longer exists at path \"%s\"."
% [mod_id, mod_list_entry.zip_path],
LOG_NAME,
true
)
updated_mod_list.erase(mod_id)
continue
updated_mod_list[mod_id] = mod_list_entry
return updated_mod_list
# Generates a dictionary with data to be stored for each mod.
static func _generate_mod_list() -> Dictionary:
var mod_list := {}
# Create a mod_list with the currently loaded mods
for mod_id in ModLoaderStore.mod_data.keys():
mod_list[mod_id] = _generate_mod_list_entry(mod_id, true)
# Add the deactivated mods to the list
for mod_id in ModLoaderStore.ml_options.disabled_mods:
mod_list[mod_id] = _generate_mod_list_entry(mod_id, false)
return mod_list
# Generates a mod list entry dictionary with the given mod ID and active status.
# If the mod has a config schema, sets the 'current_config' key to the current_config stored in the Mods ModData.
static func _generate_mod_list_entry(mod_id: String, is_active: bool) -> Dictionary:
var mod_list_entry := {}
# Set the mods active state
mod_list_entry.is_active = is_active
# Set the mods zip path if available
if ModLoaderStore.mod_data.has(mod_id):
mod_list_entry.zip_path = ModLoaderStore.mod_data[mod_id].zip_path
# Set the current_config if the mod has a config schema and is active
if is_active and not ModLoaderConfig.get_config_schema(mod_id).is_empty():
var current_config: ModConfig = ModLoaderStore.mod_data[mod_id].current_config
if current_config and current_config.is_valid:
# Set to the current_config name if valid
mod_list_entry.current_config = current_config.name
else:
# If not valid revert to the default config
mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME
return mod_list_entry
# Handles the activation or deactivation of a mod in a user profile.
static func _set_mod_state(mod_id: String, profile_name: String, should_activate: bool, force := false) -> bool:
# Verify whether the mod_id is present in the profile's mod_list.
if not _is_mod_id_in_mod_list(mod_id, profile_name):
return false
# Handle mod state
# Set state in the ModData
var was_toggled: bool = ModLoaderStore.mod_data[mod_id].set_mod_state(should_activate, force)
if not was_toggled:
return false
# Set state for user profile
ModLoaderStore.user_profiles[profile_name].mod_list[mod_id].is_active = should_activate
# Save profiles to the user profiles JSON file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Mod activation state changed: mod_id=%s should_activate=%s profile_name=%s" % [mod_id, should_activate, profile_name], LOG_NAME)
return is_save_success
# Checks whether a given mod_id is present in the mod_list of the specified user profile.
# Returns True if the mod_id is present, False otherwise.
static func _is_mod_id_in_mod_list(mod_id: String, profile_name: String) -> bool:
# Get the user profile
var user_profile := get_profile(profile_name)
if not user_profile:
# Return false if there is an error getting the user profile
return false
# Return false if the mod_id is not in the profile's mod_list
if not user_profile.mod_list.has(mod_id):
ModLoaderLog.error("Mod id \"%s\" not found in the \"mod_list\" of user profile \"%s\"." % [mod_id, profile_name], LOG_NAME)
return false
# Return true if the mod_id is in the profile's mod_list
return true
# Creates a new Profile with the given name and mod list.
# Returns the newly created Profile object.
static func _create_new_profile(profile_name: String, mod_list: Dictionary) -> ModUserProfile:
var new_profile := ModUserProfile.new()
# If no name is provided, log an error and return null
if profile_name == "":
ModLoaderLog.error("Please provide a name for the new profile", LOG_NAME)
return null
# Set the profile name
new_profile.name = profile_name
# If no mods are specified in the mod_list, log a warning and return the new profile
if mod_list.keys().size() == 0:
ModLoaderLog.info("No mod_ids inside \"mod_list\" for user profile \"%s\" " % profile_name, LOG_NAME)
return new_profile
# Set the mod_list
new_profile.mod_list = _update_mod_list(mod_list)
return new_profile
# Loads user profiles from the JSON file and adds them to ModLoaderStore.
static func _load() -> bool:
# Load JSON data from the user profiles file
var data := _ModLoaderFile.get_json_as_dict(FILE_PATH_USER_PROFILES)
# If there is no data, log an error and return
if data.is_empty():
ModLoaderLog.error("No profile file found at \"%s\"" % FILE_PATH_USER_PROFILES, LOG_NAME)
return false
# Loop through each profile in the data and add them to ModLoaderStore
for profile_name in data.profiles.keys():
# Get the profile data from the JSON object
var profile_data: Dictionary = data.profiles[profile_name]
# Create a new profile object and add it to ModLoaderStore.user_profiles
var new_profile := _create_new_profile(profile_name, profile_data.mod_list)
ModLoaderStore.user_profiles[profile_name] = new_profile
# Set the current user profile to the one specified in the data
ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[data.current_profile]
return true
# Saves the user profiles in the ModLoaderStore to the user profiles JSON file.
static func _save() -> bool:
# Initialize a dictionary to hold the serialized user profiles data
var save_dict := {
"current_profile": "",
"profiles": {}
}
# Set the current profile name in the save_dict
save_dict.current_profile = ModLoaderStore.current_user_profile.name
# Serialize the mod_list data for each user profile and add it to the save_dict
for profile_name in ModLoaderStore.user_profiles.keys():
var profile: ModUserProfile = ModLoaderStore.user_profiles[profile_name]
# Init the profile dict
save_dict.profiles[profile.name] = {}
# Init the mod_list dict
save_dict.profiles[profile.name].mod_list = profile.mod_list
# Save the serialized user profiles data to the user profiles JSON file
return _ModLoaderFile.save_dictionary_to_json_file(save_dict, FILE_PATH_USER_PROFILES)

View File

@@ -0,0 +1 @@
uid://c0u28df0ffhan