Compare commits

...

4 commits

Author SHA1 Message Date
04caa89093 a lot of improvments 2025-08-02 00:38:02 +02:00
33188c1380 AssetLoader + Modinfo object 2025-08-01 18:02:47 +02:00
3cd2327edc working on mod loader
should i enable the modload inside the engine itself ?
2025-08-01 01:52:26 +02:00
63cca5c2fb modloader prototype 2025-08-01 01:46:14 +02:00
15 changed files with 341 additions and 1 deletions

153
assetloader/AssetLoader.gd Normal file
View file

@ -0,0 +1,153 @@
@tool
extends Object
class_name AssetLoader
#region Declaration ---
# === CONST ===
## If you want to change the mod extension. Keep in mind that it's still a json under the hood
const MOD_INFOS_EXTENSION = "modinfo"
## If you want to change the localization extension. Keep in mind that it's still a json under the hood
const LOC_EXTENSION = "loc"
## Path to the mods folder : [param res://mods] by default
## [br]
## (is not a const but should be treated as such, so the uppercase)
var ASSETS_PATH
# === VAR ===
var dir:DirAccess
var mod_folders:PackedStringArray
var critical_error := false
var mod_manifests: Dictionary[StringName,ModManifest]
var mod_localizations: Dictionary[StringName,Dictionary] # {mod_id: {locale: {key: value}}}
# === SIGNALS ===
signal loading_finished
#endregion ---
func _init() -> void:
print_verbose("------ MOD LOADING STARTED ------")
# Use res://mods/ in editor, executable's mods/ folder in exported builds
if OS.has_feature("editor"):
ASSETS_PATH = "res://"+ProjectSettings.get_setting("game/mods/mod_folder_name", "mods")
else:
# fallbakc to use mods folder next to executable
ASSETS_PATH = OS.get_executable_path().get_base_dir() + "/" + ProjectSettings.get_setting("game/mods/mod_folder_name", "mods")
dir = DirAccess.open(ASSETS_PATH)
print_debug(ASSETS_PATH)
if not dir:
push_error("AssetLoader: Mods folder not found at '%s'" % ASSETS_PATH)
push_error("AssetLoader:",DirAccess.get_open_error())
_show_error_popup("Mods folder not found at '%s'" % ASSETS_PATH)
critical_error = true
return
mod_folders = dir.get_directories()
if mod_folders.is_empty():
push_error("AssetLoader: Mods folder '%s' is empty — no mods to load." % ASSETS_PATH)
_show_error_popup("Mods folder '%s' is empty — no mods to load." % ASSETS_PATH)
critical_error = true
return
# === PRIVATE ===
## This will show a native MessageBox on Windows,
## a native dialog on macOS, and GTK/QT dialog on Linux.
func _show_error_popup(message: String) -> void:
OS.alert("AssetLoader:"+message, "AssetLoader:Error")
# === PUBLIC ===
func load_all():
load_mods()
load_mods_content()
## Load and unpack all .pck before serialization
func load_mods():
for mod in mod_folders:
var mod_name = mod
var mod_path = ASSETS_PATH + "/" + mod_name
var manifest_path = mod_path + "/" + mod_name + "." + MOD_INFOS_EXTENSION
if FileAccess.file_exists(manifest_path):
var manifest_file := FileAccess.open(manifest_path, FileAccess.READ)
var manifest: ModManifest = ModManifest.new_from_file(manifest_file)
if !manifest:continue
if !mod_manifests.has(manifest.id):
mod_manifests[manifest.id] = manifest
manifest.path = mod_path
print_verbose("Mod manifest is loaded: %s" % manifest.name)
else:
push_warning("Another mod as the same id:\n %s will not be loaded" % manifest_path)
else:
push_warning("No manifest found in %s" % manifest_path)
for mod in mod_manifests:
print("Mod loaded: %s" % mod)
func load_localizations():
if mod_manifests.size() == 0:
print_verbose("No mods to load localizations from.")
return
for mod_id in mod_manifests:
var mod_path: String = mod_manifests[mod_id]["path"]
var mod_name: String = mod_path.get_file()
mod_localizations[mod_id] = {}
dir.change_dir(mod_path)
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if file_name.ends_with("." + LOC_EXTENSION):
var locale = file_name.get_basename().get_extension()
var loc_path = mod_path + "/" + file_name
var loc_file = FileAccess.open(loc_path, FileAccess.READ)
if loc_file:
var loc_data: Dictionary = JSON.parse_string(loc_file.get_as_text())
if typeof(loc_data) == TYPE_DICTIONARY:
mod_localizations[mod_id][locale] = loc_data
print_verbose("Loaded localization '%s' for mod '%s'" % [locale, mod_id])
else:
push_warning("Invalid localization file: %s" % loc_path)
else:
push_warning("Failed to open localization file: %s" % loc_path)
file_name = dir.get_next()
dir.list_dir_end()
func register_localizations():
for mod_id in mod_localizations:
for locale in mod_localizations[mod_id]:
var translation = Translation.new()
translation.locale = locale
for key in mod_localizations[mod_id][locale]:
translation.add_message(key, mod_localizations[mod_id][locale][key])
TranslationServer.add_translation(translation)
func load_mods_content():
if OS.has_feature("editor"):
# Assets should already be loaded/imported.
# FIXME: here we should implement something to serialize ? maybe not.
return
if mod_manifests.size() == 0:
print_verbose("No mods to load content from.")
return
for mod_id in mod_manifests:
var modpacks:Array = mod_manifests[mod_id].get_mod_packs()
for pack in modpacks:
# Load assets from the PCKs (mounted at res://)
var pck_path:StringName = mod_manifests[mod_id].path+ "/" + pack + ".pck"
if dir.file_exists(pck_path):
if ProjectSettings.load_resource_pack(pck_path):
print_verbose("Loaded PCK for mod '%s': %s" % [mod_id, pck_path])
mod_manifests[mod_id]._validated = true
else:
push_warning("Failed to load PCK: %s" % pck_path)
else:
push_warning("PCK not found: %s" % pck_path)

View file

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

View file

@ -0,0 +1,77 @@
extends RefCounted
class_name ModManifest
## Object for easier json manifest operations.
#region declaration
var id: StringName ## Mod unique id. Internal usage only.
var name: String ## Displayed mod name
var version: String ## Displayed mod version
var desc: String ## Displayed mod description
var author:String ## Mod Author
var dependencies := [] ## Mod dependecies, optional.
var tags:= [] ## ["units", "vehicles", "buildings", "textures", "maps", "quests"]
var packs := [] ## Optional. [br] By default [param mod_id.pck] is loaded.
var _validated = false ## true if the manifest as a correct syntax
var path:StringName ## set if the mode is loaded
#endregion
# === Init === # Initialization logic, if needed
func _init(_id:String,_name:=_id,_desc:="",_dependencies:=[],_packs:=[],_author := "",_version:="1.0",_tags:=[]) -> void:
if _id == "":
push_warning("Mod id cannot be null, fallback to "+_name)
self.id = StringName(_name.strip_edges())
else:
self.id = StringName(_id)
self.name= _name
self.version = _version
self.desc=_desc
self.author = _author
self.dependencies = _dependencies
self.tags= _tags
self.packs =_packs
# === PUBLIC FUNCTIONS ===
func is_valid() -> bool:
return _validated
## Return [param null] if the json is not parsed correctly
static func new_from_file(_manifest_file:FileAccess) -> ModManifest:
var man_dic:Dictionary = JSON.parse_string(_manifest_file.get_as_text())
if !man_dic:
push_warning("Invalid manifest format: %s" % _manifest_file.get_path())
return null
var _id: String
_id = man_dic.get("id",_manifest_file.get_path().get_file().get_basename().strip_edges()) # ugly. But should fallback on a generated one.
return ModManifest.new(
_id,
man_dic.get("name", _id),
man_dic.get("desc", ""),
man_dic.get("dependencies", []),
man_dic.get("packs", []),
man_dic.get("author", ""),
str(man_dic.get("version", "1.0")),
man_dic.get("tags", [])
)
## Check if the array is empty, if not, return the packs and the main one if it's not on the list.
func get_mod_packs() -> Array[String]:
if packs.size() > 0:
# check if the main pack is in the list
if packs.has(self.id):
return self.packs
else:
var pck = self.packs
pck.append(id)
return pck
return [id]
# === PRIVATE FUNCTIONS === # use _underscore() to make the difference between private and public functions
# ====================

View file

@ -0,0 +1 @@
uid://5ii0g6tl4dpj

24
autoloads/bootstrap.gd Normal file
View file

@ -0,0 +1,24 @@
extends Node
## Should always be at the top of autoloads
func _ready() -> void:
var loader := AssetLoader.new()
# No point to load the game if no asset are loaded
if loader.critical_error:
Engine.get_main_loop().quit(1)
return
loader.load_all() # TODO: Making it async
_start_game()
loader = null
## This will show a native MessageBox on Windows,
## a native dialog on macOS, and GTK/QT dialog on Linux.
func _show_error_popup(context:String, message: String) -> void:
OS.alert(context+":"+message, context+":Error")
func _start_game() -> void:
print("Game starting...")
#TODO: Load main menu or world scene

View file

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

View file

@ -16,7 +16,7 @@ func _process(_delta):
#update_cursor(shape) #update_cursor(shape)
func update_cursor(image: Resource, shape: Input.CursorShape = 0, hotspot: Vector2 = Vector2(0, 0)): func update_cursor(image: Resource, _shape:= 0 as Input.CursorShape, hotspot: Vector2 = Vector2(0, 0)):
# Get the custom cursor data from the main script # Get the custom cursor data from the main script
if image != null: if image != null:
texture_rect.texture = image texture_rect.texture = image

View file

@ -0,0 +1,23 @@
# meta-name: Clean Code Template
# meta-description: Use this format to structure your code into clear sections
# meta-default: true
# meta-space-indent: 4
extends _BASE_
#region declaration
# === CONST === # Constants and immutables, in UPPERCASE
# === STATIC === # Static variables/functions
#endregion
# === Init === # Initialization logic, if needed
func _init() -> void:
pass
# === PUBLIC FUNCTIONS ===
# === PRIVATE FUNCTIONS === # use _underscore() to make the difference between private and public functions
# ====================

View file

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

25
mods/README.md Normal file
View file

@ -0,0 +1,25 @@
# Modding
By default a modding system is made.
Each mods need to be in the mod folder, each folder is a mod containing at least a `mod_id.modinfo` and a `mod_id.pck` and optionnaly a `mod_id.<locale>.loc`
## modinfo example
```json
{
"id": "mod_id",
"name": "Mod Displayed Name",
"version": "1.0",
"desc": "Mod displayed description",
"author": "Mod Author",
"dependencies": [],
"tags": ["units", "vehicles", "buildings", "textures", "maps", "quests"],
"packs": ["mod_id", "addon_pack"]
}
```
*id* (string) : should be the unique name of the mod. Only used internally. should be the same as the modinfo and the folder name
*name* (string) : the displayed name of the mode
*version* (string) : to keep track of the version of you mod. displayed in the mod manager
*author* (string) : you, the awesome modder
*dependecies* (array of strings) : list of ids of requiered mod. be sure to load them before this one

View file

@ -0,0 +1,9 @@
{
"id": "base_content",
"name": "Core Game Elements",
"version": 1.0,
"desc": "Factions, unités, bâtiments et cartes de base.",
"author": "TonStudio",
"dependencies": [],
"tags": ["units","vehicles","buildings","textures","maps","quests"]
}

View file

@ -0,0 +1,9 @@
{
"id": "base_content",
"name": "Core Game Elements",
"version": 1.0,
"desc": "Factions, unités, bâtiments et cartes de base.",
"author": "TonStudio",
"dependencies": [],
"tags": ["units","vehicles","buildings","textures","maps","quests"]
}

View file

10
mods/modinfo_example.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "mod_id",
"name": "Mod Displayed Name",
"version": "1.0",
"desc": "Mod displayed description",
"author": "Mod Author",
"dependencies": [],
"tags": ["units", "vehicles", "buildings", "textures", "maps", "quests"],
"packs": ["mod_id", "addon_pack"]
}

View file

@ -21,6 +21,7 @@ config/windows_native_icon="res://4589AD_icon.ico"
[autoload] [autoload]
Bootstrap="*res://autoloads/bootstrap.gd"
DebugTools="*res://dev/debug_tools.tscn" DebugTools="*res://dev/debug_tools.tscn"
[debug] [debug]
@ -37,6 +38,11 @@ window/size/window_height_override=720
mouse_cursor/custom_image="uid://dp4ed16rb1754" mouse_cursor/custom_image="uid://dp4ed16rb1754"
mouse_cursor/custom_image_hotspot=Vector2(14, 2) mouse_cursor/custom_image_hotspot=Vector2(14, 2)
[editor]
script/search_in_file_extensions=PackedStringArray("gd", "gdshader", "json", "modinfo")
script/templates_search_path="res://editor/script_templates"
[editor_plugins] [editor_plugins]
enabled=PackedStringArray("res://addons/SimpleFormatOnSave/plugin.cfg", "res://addons/Todo_Manager/plugin.cfg") enabled=PackedStringArray("res://addons/SimpleFormatOnSave/plugin.cfg", "res://addons/Todo_Manager/plugin.cfg")