mod.gd 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. class_name ModLoaderMod
  2. extends Object
  3. ##
  4. ## This Class provides helper functions to build mods.
  5. ##
  6. ## @tutorial(Script Extensions): https://wiki.godotmodding.com/#/guides/modding/script_extensions
  7. ## @tutorial(Script Hooks): https://wiki.godotmodding.com/#/guides/modding/script_hooks
  8. ## @tutorial(Mod Structure): https://wiki.godotmodding.com/#/guides/modding/mod_structure
  9. ## @tutorial(Mod Files): https://wiki.godotmodding.com/#/guides/modding/mod_files
  10. const LOG_NAME := "ModLoader:Mod"
  11. ## Installs a script extension that extends a vanilla script.[br]
  12. ## [br]
  13. ## [b]Parameters:[/b][br]
  14. ## - [param child_script_path] ([String]): The path to the mod's extender script.[br]
  15. ## [br]
  16. ## [b]Returns:[/b][br]
  17. ## - No return value[br]
  18. ## [br]
  19. ## This is the preferred way of modifying a vanilla [Script][br]
  20. ## Since Godot 4, extensions can cause issues with scripts that use [code]class_name[/code]
  21. ## and should be avoided if present.[br]
  22. ## See [method add_hook] for those cases.[br]
  23. ## [br]
  24. ## The [param child_script_path] should point to your mod's extender script.[br]
  25. ## Example: [code]"MOD/extensions/singletons/utils.gd"[/code][br]
  26. ## Inside the extender script, include [code]extends {target}[/code] where [code]{target}[/code] is the vanilla path.[br]
  27. ## Example: [code]extends "res://singletons/utils.gd"[/code].[br]
  28. ## ===[br]
  29. ## [b]Note:[/b][br]
  30. ## Your extender script doesn't have to follow the same directory path as the vanilla file,
  31. ## but it's good practice to do so.[br]
  32. ## ===[br]
  33. ## [br]
  34. static func install_script_extension(child_script_path: String) -> void:
  35. var mod_id: String = _ModLoaderPath.get_mod_dir(child_script_path)
  36. var mod_data: ModData = get_mod_data(mod_id)
  37. if not ModLoaderStore.saved_extension_paths.has(mod_data.manifest.get_mod_id()):
  38. ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()] = []
  39. ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()].append(child_script_path)
  40. # If this is called during initialization, add it with the other
  41. # extensions to be installed taking inheritance chain into account
  42. if ModLoaderStore.is_initializing:
  43. ModLoaderStore.script_extensions.push_back(child_script_path)
  44. # If not, apply the extension directly
  45. else:
  46. _ModLoaderScriptExtension.apply_extension(child_script_path)
  47. ## Adds all methods from a file as hooks. [br]
  48. ## [br]
  49. ## [b]Parameters:[/b][br]
  50. ## - [param vanilla_script_path] ([String]): The path to the script which will be hooked.[br]
  51. ## - [param hook_script_path] ([String]): The path to the script containing hooks.[br]
  52. ## [br]
  53. ## [b]Returns:[/b][br]
  54. ## - No return value[br]
  55. ## [br]
  56. ## The file needs to extend [Object].[br]
  57. ## The methods in the file need to have the exact same name as the vanilla method
  58. ## they intend to hook, all mismatches will be ignored. [br]
  59. ## See: [method add_hook]
  60. ## [br]
  61. ## [b]Examples:[/b][br]
  62. ## [codeblock]
  63. ## ModLoaderMod.install_script_hooks(
  64. ## "res://tools/utilities.gd",
  65. ## extensions_dir_path.path_join("tools/utilities-hook.gd")
  66. ## )
  67. ## [/codeblock]
  68. static func install_script_hooks(vanilla_script_path: String, hook_script_path: String) -> void:
  69. var hook_script := load(hook_script_path) as GDScript
  70. var hook_script_instance := hook_script.new()
  71. # Every script that inherits RefCounted will be cleaned up by the engine as
  72. # soon as there are no more references to it. If the reference is gone
  73. # the method can't be called and everything returns null.
  74. # Only Object won't be removed, so we can use it here.
  75. if hook_script_instance is RefCounted:
  76. ModLoaderLog.fatal(
  77. "Scripts holding mod hooks should always extend Object (%s)"
  78. % hook_script_path, LOG_NAME
  79. )
  80. var vanilla_script := load(vanilla_script_path) as GDScript
  81. var vanilla_methods := vanilla_script.get_script_method_list().map(
  82. func(method: Dictionary) -> String:
  83. return method.name
  84. )
  85. var methods := hook_script.get_script_method_list()
  86. for hook in methods:
  87. if hook.name in vanilla_methods:
  88. ModLoaderMod.add_hook(Callable(hook_script_instance, hook.name), vanilla_script_path, hook.name)
  89. continue
  90. ModLoaderLog.debug(
  91. 'Skipped adding hook "%s" (not found in vanilla script %s)'
  92. % [hook.name, vanilla_script_path], LOG_NAME
  93. )
  94. if not OS.has_feature("editor"):
  95. continue
  96. vanilla_methods.sort_custom((
  97. func(a_name: String, b_name: String, target_name: String) -> bool:
  98. return a_name.similarity(target_name) > b_name.similarity(target_name)
  99. ).bind(hook.name))
  100. var closest_vanilla: String = vanilla_methods.front()
  101. if closest_vanilla.similarity(hook.name) > 0.8:
  102. ModLoaderLog.hint(
  103. 'Did you mean "%s" instead of "%s"?'
  104. % [closest_vanilla, hook.name], LOG_NAME
  105. )
  106. ## Adds a hook, a custom mod function, to a vanilla method.[br]
  107. ## [br]
  108. ## [b]Parameters:[/b][br]
  109. ## - [param mod_callable] ([Callable]): The function that will executed when
  110. ## the vanilla method is executed. When writing a mod callable, make sure
  111. ## that it [i]always[/i] receives a [ModLoaderHookChain] object as first argument,
  112. ## which is used to continue down the hook chain (see: [method ModLoaderHookChain.execute_next])
  113. ## and allows manipulating parameters before and return values after the
  114. ## vanilla method is called. [br]
  115. ## - [param script_path] ([String]): Path to the vanilla script that holds the method.[br]
  116. ## - [param method_name] ([String]): The method the hook will be applied to.[br]
  117. ## [br]
  118. ## [b]Returns:[/b][br][br]
  119. ## - No return value[br]
  120. ## [br]
  121. ## Opposed to script extensions, hooks can be applied to scripts that use
  122. ## [code]class_name[/code] without issues.[br]
  123. ## If possible, prefer [method install_script_extension].[br]
  124. ## [br]
  125. ## [b]Examples:[/b][br]
  126. ## [br]
  127. ## Given the following vanilla script [code]main.gd[/code]
  128. ## [codeblock]
  129. ## class_name MainGame
  130. ## extends Node2D
  131. ##
  132. ## var version := "vanilla 1.0.0"
  133. ##
  134. ##
  135. ## func _ready():
  136. ## $CanvasLayer/Control/Label.text = "Version: %s" % version
  137. ## print(Utilities.format_date(15, 11, 2024))
  138. ## [/codeblock]
  139. ##
  140. ## It can be hooked in [code]mod_main.gd[/code] like this
  141. ## [codeblock]
  142. ## func _init() -> void:
  143. ## ModLoaderMod.add_hook(change_version, "res://main.gd", "_ready")
  144. ## ModLoaderMod.add_hook(time_travel, "res://tools/utilities.gd", "format_date")
  145. ## # Multiple hooks can be added to a single method.
  146. ## ModLoaderMod.add_hook(add_season, "res://tools/utilities.gd", "format_date")
  147. ##
  148. ##
  149. ## # The script we are hooking is attached to a node, which we can get from reference_object
  150. ## # then we can change any variables it has
  151. ## func change_version(chain: ModLoaderHookChain) -> void:
  152. ## # Using a typecast here (with "as") can help with autocomplete and avoiding errors
  153. ## var main_node := chain.reference_object as MainGame
  154. ## main_node.version = "Modloader Hooked!"
  155. ## # _ready, which we are hooking, does not have any arguments
  156. ## chain.execute_next()
  157. ##
  158. ##
  159. ## # Parameters can be manipulated easily by changing what is passed into .execute_next()
  160. ## # The vanilla method (Utilities.format_date) takes 3 arguments, our hook method takes
  161. ## # the ModLoaderHookChain followed by the same 3
  162. ## func time_travel(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
  163. ## print("time travel!")
  164. ## year -= 100
  165. ## # Just the vanilla arguments are passed along in the same order, wrapped into an Array
  166. ## var val = chain.execute_next([day, month, year])
  167. ## return val
  168. ##
  169. ##
  170. ## # The return value can be manipulated by calling the next hook (or vanilla) first
  171. ## # then changing it and returning the new value.
  172. ## func add_season(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
  173. ## var output = chain.execute_next([day, month, year])
  174. ## match month:
  175. ## 12, 1, 2:
  176. ## output += ", Winter"
  177. ## 3, 4, 5:
  178. ## output += ", Spring"
  179. ## 6, 7, 8:
  180. ## output += ", Summer"
  181. ## 9, 10, 11:
  182. ## output += ", Autumn"
  183. ## return output
  184. ## [/codeblock]
  185. ##
  186. static func add_hook(mod_callable: Callable, script_path: String, method_name: String) -> void:
  187. _ModLoaderHooks.add_hook(mod_callable, script_path, method_name)
  188. ## Registers an array of classes to the global scope since Godot only does that in the editor.[br]
  189. ## [br]
  190. ## [b]Parameters:[/b][br]
  191. ## - [param new_global_classes] ([Array]): An array of class definitions to be registered.[br]
  192. ## [br]
  193. ## [b]Returns:[/b][br]
  194. ## - No return value[br]
  195. ## [br]
  196. ## Format: [code]{ "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" }[/code][br]
  197. ## [br]
  198. ## ===[br]
  199. ## [b]Tip:[/b][color=tip][/color][br]
  200. ## You can find these easily in the project.godot file under `_global_script_classes`[br]
  201. ## (but you should only include classes belonging to your mod)[br]
  202. ## ===[br]
  203. static func register_global_classes_from_array(new_global_classes: Array) -> void:
  204. ModLoaderUtils.register_global_classes_from_array(new_global_classes)
  205. var _savecustom_error: int = ProjectSettings.save_custom(_ModLoaderPath.get_override_path())
  206. ## Adds a translation file.[br]
  207. ## [br]
  208. ## [b]Parameters:[/b][br]
  209. ## - [param resource_path] ([String]): The path to the translation resource file.[br]
  210. ## [b]Returns:[/b][br]
  211. ## - No return value[br]
  212. ## [br]
  213. ## ===[br]
  214. ## [b]Note:[/b][br]
  215. ## The [code].translation[/code] file should have been created by the Godot editor already, usually when importing a CSV file.
  216. ## The translation file should named [code]name.langcode.translation[/code] -> [code]mytranslation.en.translation[/code].[br]
  217. ## ===[br]
  218. static func add_translation(resource_path: String) -> void:
  219. if not _ModLoaderFile.file_exists(resource_path):
  220. ModLoaderLog.fatal("Tried to load a position resource from a file that doesn't exist. The invalid path was: %s" % [resource_path], LOG_NAME)
  221. return
  222. var translation_object: Translation = load(resource_path)
  223. if translation_object:
  224. TranslationServer.add_translation(translation_object)
  225. ModLoaderLog.info("Added Translation from Resource -> %s" % resource_path, LOG_NAME)
  226. else:
  227. ModLoaderLog.fatal("Failed to load translation at path: %s" % [resource_path], LOG_NAME)
  228. ## Marks the given scene for to be refreshed. It will be refreshed at the correct point in time later.[br]
  229. ## [br]
  230. ## [b]Parameters:[/b][br]
  231. ## - [param scene_path] ([String]): The path to the scene file to be refreshed.
  232. ## [br]
  233. ## [b]Returns:[/b][br]
  234. ## - No return value[br]
  235. ## [br]
  236. ## ===[br]
  237. ## [b]Note:[/b][color=abstract "Version"][/color][br]
  238. ## This function requires Godot 4.3 or higher.[br]
  239. ## ===[br]
  240. ## [br]
  241. ## This function is useful if a script extension is not automatically applied.
  242. ## This situation can occur when a script is attached to a preloaded scene.
  243. ## If you encounter issues where your script extension is not working as expected,
  244. ## try to identify the scene to which it is attached and use this method to refresh it.
  245. ## This will reload already loaded scenes and apply the script extension.
  246. ## [br]
  247. static func refresh_scene(scene_path: String) -> void:
  248. if scene_path in ModLoaderStore.scenes_to_refresh:
  249. return
  250. ModLoaderStore.scenes_to_refresh.push_back(scene_path)
  251. ModLoaderLog.debug("Added \"%s\" to be refreshed." % scene_path, LOG_NAME)
  252. ## Extends a specific scene by providing a callable function to modify it.
  253. ## [br]
  254. ## [b]Parameters:[/b][br]
  255. ## - [param scene_vanilla_path] ([String]): The path to the vanilla scene file.[br]
  256. ## - [param edit_callable] ([Callable]): The callable function to modify the scene.[br]
  257. ## [br]
  258. ## [b]Returns:[/b][br]
  259. ## - No return value[br]
  260. ## [br]
  261. ## The callable receives an instance of the "vanilla_scene" as the first parameter.[br]
  262. static func extend_scene(scene_vanilla_path: String, edit_callable: Callable) -> void:
  263. if not ModLoaderStore.scenes_to_modify.has(scene_vanilla_path):
  264. ModLoaderStore.scenes_to_modify[scene_vanilla_path] = []
  265. ModLoaderStore.scenes_to_modify[scene_vanilla_path].push_back(edit_callable)
  266. ## Gets the [ModData] from the provided namespace.[br]
  267. ## [br]
  268. ## [b]Parameters:[/b][br]
  269. ## - [param mod_id] ([String]): The ID of the mod.[br]
  270. ## [br]
  271. ## [b]Returns:[/b][br]
  272. ## - [ModData]: The [ModData] associated with the provided [code]mod_id[/code], or null if the [code]mod_id[/code] is invalid.[br]
  273. static func get_mod_data(mod_id: String) -> ModData:
  274. if not ModLoaderStore.mod_data.has(mod_id):
  275. ModLoaderLog.error("%s is an invalid mod_id" % mod_id, LOG_NAME)
  276. return null
  277. return ModLoaderStore.mod_data[mod_id]
  278. ## Gets the [ModData] of all loaded Mods as [Dictionary].[br]
  279. ## [br]
  280. ## [b]Returns:[/b][br]
  281. ## - [Dictionary]: A dictionary containing the [ModData] of all loaded mods.[br]
  282. static func get_mod_data_all() -> Dictionary:
  283. return ModLoaderStore.mod_data
  284. ## Returns the path to the directory where unpacked mods are stored.[br]
  285. ## [br]
  286. ## [b]Returns:[/b][br]
  287. ## - [String]: The path to the unpacked mods directory.[br]
  288. static func get_unpacked_dir() -> String:
  289. return _ModLoaderPath.get_unpacked_mods_dir_path()
  290. ## Returns true if the mod with the given [code]mod_id[/code] was successfully loaded.[br]
  291. ## [br]
  292. ## [b]Parameters:[/b][br]
  293. ## - [param mod_id] ([String]): The ID of the mod.[br]
  294. ## [br]
  295. ## [b]Returns:[/b][br]
  296. ## - [bool]: true if the mod is loaded, false otherwise.[br]
  297. static func is_mod_loaded(mod_id: String) -> bool:
  298. if ModLoaderStore.is_initializing:
  299. ModLoaderLog.warning(
  300. "The ModLoader is not fully initialized. " +
  301. "Calling \"is_mod_loaded()\" in \"_init()\" may result in an unexpected return value as mods are still loading.",
  302. LOG_NAME
  303. )
  304. # If the mod is not present in the mod_data dictionary or the mod is flagged as not loadable.
  305. if not ModLoaderStore.mod_data.has(mod_id) or not ModLoaderStore.mod_data[mod_id].is_loadable:
  306. return false
  307. return true
  308. ## Returns true if the mod with the given mod_id was successfully loaded and is currently active.
  309. ## [br]
  310. ## Parameters:
  311. ## - [param mod_id] ([String]): The ID of the mod.
  312. ## [br]
  313. ## Returns:
  314. ## - [bool]: true if the mod is loaded and active, false otherwise.
  315. static func is_mod_active(mod_id: String) -> bool:
  316. return is_mod_loaded(mod_id) and ModLoaderStore.mod_data[mod_id].is_active