12. Mod模组系统设计
2026/4/14大约 10 分钟
Mod模组系统设计
Mod(模组)是玩家或社区开发者给游戏添加新内容的扩展包。想想《我的世界》的 Mod 生态——有人加新方块,有人加新怪物,有人改整个游戏玩法。一个好的 Mod 系统能让你的游戏"寿命"延长好几倍,因为社区会不断创造新内容。
本章你将学到
- Mod 系统的设计思路与架构
- Mod 资源包结构设计
- Mod 加载器实现(扫描、解析、加载)
- Mod API 设计(给 Mod 暴露哪些接口)
- Mod 冲突处理
- 工坊系统集成思路
Mod 系统设计思路
核心原则
一个好的 Mod 系统应该遵循以下原则:
- 隔离性:每个 Mod 运行在独立环境中,一个 Mod 崩溃不影响其他 Mod 和游戏本身
- 可扩展性:Mod 可以添加新内容,也可以修改已有内容
- 有序性:Mod 之间有加载顺序,确保依赖关系正确
- 安全性:限制 Mod 能做的事情,防止恶意代码
Mod 资源包结构
每个 Mod 是一个独立的文件夹(或压缩包),包含资源文件和描述信息。
mods/
├── my_weapon_pack/ # Mod 根目录
│ ├── manifest.json # Mod 描述文件(必须)
│ ├── preview.png # Mod 预览图
│ ├── scripts/ # Mod 脚本
│ │ ├── weapon_loader.gd # 脚本文件
│ │ └── special_sword.gd
│ ├── assets/ # Mod 资源
│ │ ├── models/ # 3D 模型
│ │ │ └── flame_sword.glb
│ │ ├── textures/ # 贴图
│ │ │ └── flame_sword.png
│ │ └── sounds/ # 音效
│ │ └── flame_slash.ogg
│ └── data/ # Mod 数据
│ ├── weapons.json # 武器数据定义
│ └── recipes.json # 合成配方manifest.json 格式
{
"id": "my_weapon_pack",
"name": "烈焰武器包",
"version": "1.2.0",
"game_version": ">=1.0.0",
"author": "ModderName",
"description": "添加5把全新火焰主题武器",
"dependencies": [
{ "id": "core_api", "version": ">=1.0.0" }
],
"load_after": ["core_api", "base_weapons"],
"entry_script": "scripts/weapon_loader.gd",
"tags": ["weapons", "combat"]
}Mod 加载器实现
Mod 加载器是整个系统的核心,负责扫描、解析、排序和加载所有 Mod。
C#
using Godot;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
public class ModInfo
{
public string Id { get; set; }
public string Name { get; set; }
public string Version { get; set; }
public string GameVersion { get; set; }
public string Author { get; set; }
public string Description { get; set; }
public List<ModDependency> Dependencies { get; set; } = new();
public List<string> LoadAfter { get; set; } = new();
public string EntryScript { get; set; }
public string ModPath { get; set; }
public bool IsLoaded { get; set; }
public bool HasError { get; set; }
public string ErrorMessage { get; set; }
}
public class ModDependency
{
public string Id { get; set; }
public string Version { get; set; }
}
public partial class ModLoader : Node
{
private const string MODS_DIR = "user://mods/";
private Dictionary<string, ModInfo> _loadedMods = new();
private List<string> _loadOrder = new();
// 信号:Mod 相关事件
[Signal] public delegate void ModLoadedEventHandler(string modId);
[Signal] public delegate void ModLoadFailedEventHandler(string modId, string error);
public override void _Ready()
{
DirAccess.MakeDirRecursiveAbsolute(MODS_DIR);
}
/// <summary>
/// 扫描并加载所有 Mod
/// </summary>
public void LoadAllMods()
{
GD.Print("--- 开始加载 Mod ---");
// 第一步:扫描 Mod 目录
var discoveredMods = ScanModsDirectory();
GD.Print($"发现 {discoveredMods.Count} 个 Mod");
// 第二步:解析 manifest
foreach (var mod in discoveredMods)
{
ParseManifest(mod);
}
// 第三步:检查依赖关系
var validMods = discoveredMods.Where(m => !m.HasError).ToList();
_loadOrder = ResolveLoadOrder(validMods);
// 第四步:按顺序加载
foreach (var modId in _loadOrder)
{
var mod = validMods.First(m => m.Id == modId);
LoadMod(mod);
}
GD.Print($"--- Mod 加载完成: {_loadedMods.Count}/{discoveredMods.Count} 成功 ---");
}
/// <summary>
/// 扫描 Mod 目录
/// </summary>
private List<ModInfo> ScanModsDirectory()
{
var mods = new List<ModInfo>();
var dir = DirAccess.Open(MODS_DIR);
if (dir == null) return mods;
dir.ListDirBegin();
string folderName = dir.GetNext();
while (folderName != "")
{
if (dir.CurrentIsDir() && !folderName.StartsWith("."))
{
string manifestPath = MODS_DIR + folderName + "/manifest.json";
if (FileAccess.FileExists(manifestPath))
{
mods.Add(new ModInfo
{
Id = folderName,
ModPath = MODS_DIR + folderName + "/"
});
}
}
folderName = dir.GetNext();
}
return mods;
}
/// <summary>
/// 解析 Mod 的 manifest.json
/// </summary>
private void ParseManifest(ModInfo mod)
{
string manifestPath = mod.ModPath + "manifest.json";
var file = FileAccess.Open(manifestPath, FileAccess.ModeFlags.Read);
if (file == null)
{
mod.HasError = true;
mod.ErrorMessage = "无法读取 manifest.json";
return;
}
string json = file.GetAsText();
file.Close();
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
mod.Id = root.GetProperty("id").GetString();
mod.Name = root.TryGetProperty("name", out var nameEl)
? nameEl.GetString() : mod.Id;
mod.Version = root.TryGetProperty("version", out var verEl)
? verEl.GetString() : "0.0.1";
mod.EntryScript = root.TryGetProperty("entry_script", out var scriptEl)
? scriptEl.GetString() : null;
GD.Print($" 解析 Mod: {mod.Name} v{mod.Version}");
}
catch (System.Exception e)
{
mod.HasError = true;
mod.ErrorMessage = $"manifest 解析失败: {e.Message}";
GD.PrintErr($" Mod {mod.Id} 解析失败: {e.Message}");
}
}
/// <summary>
/// 解析加载顺序(拓扑排序)
/// </summary>
private List<string> ResolveLoadOrder(List<ModInfo> mods)
{
var modMap = mods.ToDictionary(m => m.Id);
var inDegree = mods.ToDictionary(m => m.Id, m => 0);
var graph = new Dictionary<string, List<string>>();
foreach (var mod in mods)
{
graph[mod.Id] = new List<string>();
}
// 构建依赖图
foreach (var mod in mods)
{
if (mod.LoadAfter != null)
{
foreach (var depId in mod.LoadAfter)
{
if (modMap.ContainsKey(depId))
{
graph[depId].Add(mod.Id);
inDegree[mod.Id]++;
}
}
}
}
// 拓扑排序(Kahn 算法)
var queue = new Queue<string>();
foreach (var kvp in inDegree)
{
if (kvp.Value == 0) queue.Enqueue(kvp.Key);
}
var result = new List<string>();
while (queue.Count > 0)
{
string current = queue.Dequeue();
result.Add(current);
foreach (var next in graph[current])
{
inDegree[next]--;
if (inDegree[next] == 0)
queue.Enqueue(next);
}
}
return result;
}
/// <summary>
/// 加载单个 Mod
/// </summary>
private void LoadMod(ModInfo mod)
{
GD.Print($" 加载 Mod: {mod.Name}");
// 注册 Mod 资源路径
RegisterModResources(mod);
// 执行 Mod 入口脚本
if (!string.IsNullOrEmpty(mod.EntryScript))
{
string scriptPath = mod.ModPath + mod.EntryScript;
if (FileAccess.FileExists(scriptPath))
{
var script = GD.Load<GDScript>(scriptPath);
if (script != null)
{
var instance = script.New();
if (instance is Node modNode)
{
AddChild(modNode);
modNode.Name = "Mod_" + mod.Id;
}
}
}
}
mod.IsLoaded = true;
_loadedMods[mod.Id] = mod;
EmitSignal(SignalName.ModLoaded, mod.Id);
}
/// <summary>
/// 注册 Mod 资源到资源系统
/// </summary>
private void RegisterModResources(ModInfo mod)
{
// 将 Mod 路径注册到项目设置,使资源可以被引用
string resourcePath = mod.ModPath + "assets/";
if (DirAccess.DirExistsAbsolute(resourcePath))
{
GD.Print($" 注册资源路径: {resourcePath}");
}
}
/// <summary>
/// 获取所有已加载的 Mod 信息
/// </summary>
public Dictionary<string, ModInfo> GetLoadedMods() => _loadedMods;
}GDScript
extends Node
const MODS_DIR: String = "user://mods/"
# 已加载的 Mod 字典 { mod_id: ModInfo }
var loaded_mods: Dictionary = {}
var load_order: Array = []
# 信号:Mod 相关事件
signal mod_loaded(mod_id: String)
signal mod_load_failed(mod_id: String, error: String)
func _ready():
DirAccess.make_dir_recursive_absolute(MODS_DIR)
## 扫描并加载所有 Mod
func load_all_mods():
print("--- 开始加载 Mod ---")
# 第一步:扫描 Mod 目录
var discovered = _scan_mods_directory()
print("发现 %d 个 Mod" % discovered.size())
# 第二步:解析 manifest
for mod in discovered:
_parse_manifest(mod)
# 第三步:筛选有效的 Mod
var valid_mods = discovered.filter(func(m): return not m.has_error)
# 第四步:解析加载顺序(拓扑排序)
load_order = _resolve_load_order(valid_mods)
# 第五步:按顺序加载
for mod_id in load_order:
var mod = valid_mods.filter(func(m): return m.id == mod_id)[0]
_load_mod(mod)
print("--- Mod 加载完成: %d/%d 成功 ---" % [loaded_mods.size(), discovered.size()])
## 扫描 Mod 目录
func _scan_mods_directory() -> Array:
var mods = []
var dir = DirAccess.open(MODS_DIR)
if dir == null:
return mods
dir.list_dir_begin()
var folder_name = dir.get_next()
while folder_name != "":
if dir.current_is_dir() and not folder_name.begins_with("."):
var manifest_path = MODS_DIR + folder_name + "/manifest.json"
if FileAccess.file_exists(manifest_path):
mods.append({
"id": folder_name,
"mod_path": MODS_DIR + folder_name + "/",
"name": folder_name,
"version": "0.0.1",
"entry_script": "",
"load_after": [],
"is_loaded": false,
"has_error": false,
"error_message": ""
})
folder_name = dir.get_next()
return mods
## 解析 Mod 的 manifest.json
func _parse_manifest(mod: Dictionary):
var manifest_path = mod.mod_path + "manifest.json"
var file = FileAccess.open(manifest_path, FileAccess.READ)
if file == null:
mod.has_error = true
mod.error_message = "无法读取 manifest.json"
return
var json_string = file.get_as_text()
file.close()
var json = JSON.new()
var error = json.parse(json_string)
if error != OK:
mod.has_error = true
mod.error_message = "manifest 解析失败: " + json.error_string
return
var data = json.data
mod.id = data.get("id", mod.id)
mod.name = data.get("name", mod.id)
mod.version = data.get("version", "0.0.1")
mod.entry_script = data.get("entry_script", "")
mod.load_after = data.get("load_after", [])
print(" 解析 Mod: %s v%s" % [mod.name, mod.version])
## 解析加载顺序(拓扑排序)
func _resolve_load_order(mods: Array) -> Array:
var mod_ids = {}
for mod in mods:
mod_ids[mod.id] = mod
# 构建依赖图
var in_degree = {}
var graph = {}
for mod in mods:
in_degree[mod.id] = 0
graph[mod.id] = []
for mod in mods:
for dep_id in mod.load_after:
if mod_ids.has(dep_id):
graph[dep_id].append(mod.id)
in_degree[mod.id] += 1
# Kahn 算法拓扑排序
var queue = []
for mod_id in in_degree:
if in_degree[mod_id] == 0:
queue.append(mod_id)
var result = []
while queue.size() > 0:
var current = queue.pop_front()
result.append(current)
for next_id in graph[current]:
in_degree[next_id] -= 1
if in_degree[next_id] == 0:
queue.append(next_id)
return result
## 加载单个 Mod
func _load_mod(mod: Dictionary):
print(" 加载 Mod: ", mod.name)
# 注册 Mod 资源路径
_register_mod_resources(mod)
# 执行 Mod 入口脚本
if mod.entry_script != "":
var script_path = mod.mod_path + mod.entry_script
if FileAccess.file_exists(script_path):
var script = load(script_path)
if script:
var instance = script.new()
if instance is Node:
add_child(instance)
instance.name = "Mod_" + mod.id
mod.is_loaded = true
loaded_mods[mod.id] = mod
mod_loaded.emit(mod.id)
## 注册 Mod 资源
func _register_mod_resources(mod: Dictionary):
var resource_path = mod.mod_path + "assets/"
if DirAccess.dir_exists_absolute(resource_path):
print(" 注册资源路径: ", resource_path)
## 获取所有已加载的 Mod
func get_loaded_mods() -> Dictionary:
return loaded_modsMod API 设计
Mod API 是你给 Mod 作者提供的"接口",告诉他们可以做什么、怎么做。好的 API 应该清晰、稳定、文档完善。
API 设计原则
API 示例:武器注册系统
C#
using Godot;
using System.Collections.Generic;
/// <summary>
/// Mod 可以调用的 API 接口
/// </summary>
public partial class ModAPI : Node
{
private static ModAPI _instance;
public static ModAPI Instance => _instance;
private Dictionary<string, WeaponData> _registeredWeapons = new();
public override void _Ready()
{
_instance = this;
}
/// <summary>
/// Mod 调用此方法注册一把新武器
/// </summary>
public void RegisterWeapon(string modId, Dictionary<string, Variant> weaponData)
{
string weaponId = (string)weaponData["id"];
if (_registeredWeapons.ContainsKey(weaponId))
{
GD.PrintErr($"武器 ID 冲突: {weaponId},已被 Mod {_registeredWeapons[weaponId].ModId} 注册");
return;
}
var data = new WeaponData
{
Id = weaponId,
ModId = modId,
Name = (string)weaponData["name"],
Damage = (float)weaponData["damage"],
AttackSpeed = (float)weaponData.GetValueOrDefault("attack_speed", 1.0),
ModelPath = (string)weaponData.GetValueOrDefault("model_path", ""),
IconPath = (string)weaponData.GetValueOrDefault("icon_path", ""),
Description = (string)weaponData.GetValueOrDefault("description", "")
};
_registeredWeapons[weaponId] = data;
GD.Print($"Mod [{modId}] 注册武器: {data.Name} (伤害: {data.Damage})");
}
/// <summary>
/// Mod 调用此方法注册事件钩子
/// </summary>
public void RegisterEventHandler(string modId, string eventName, Callable callback)
{
GD.Print($"Mod [{modId}] 注册事件: {eventName}");
// 将回调添加到事件系统
}
public WeaponData GetWeapon(string weaponId)
{
return _registeredWeapons.GetValueOrDefault(weaponId);
}
public Dictionary<string, WeaponData> GetAllWeapons() => _registeredWeapons;
}
public class WeaponData
{
public string Id { get; set; }
public string ModId { get; set; }
public string Name { get; set; }
public float Damage { get; set; }
public float AttackSpeed { get; set; }
public string ModelPath { get; set; }
public string IconPath { get; set; }
public string Description { get; set; }
}GDScript
extends Node
## Mod API 单例
var _instance: Node
var _registered_weapons: Dictionary = {}
func _ready():
_instance = self
## Mod 调用此方法注册一把新武器
func register_weapon(mod_id: String, weapon_data: Dictionary):
var weapon_id: String = weapon_data["id"]
if _registered_weapons.has(weapon_id):
push_error("武器 ID 冲突: %s,已被 Mod %s 注册" % [
weapon_id, _registered_weapons[weapon_id].mod_id])
return
var data = {
"id": weapon_id,
"mod_id": mod_id,
"name": weapon_data.get("name", weapon_id),
"damage": weapon_data.get("damage", 10.0),
"attack_speed": weapon_data.get("attack_speed", 1.0),
"model_path": weapon_data.get("model_path", ""),
"icon_path": weapon_data.get("icon_path", ""),
"description": weapon_data.get("description", "")
}
_registered_weapons[weapon_id] = data
print("Mod [%s] 注册武器: %s (伤害: %.1f)" % [mod_id, data.name, data.damage])
## Mod 调用此方法注册事件钩子
func register_event_handler(mod_id: String, event_name: String, callback: Callable):
print("Mod [%s] 注册事件: %s" % [mod_id, event_name])
# 将回调添加到事件系统
## 获取武器数据
func get_weapon(weapon_id: String) -> Dictionary:
return _registered_weapons.get(weapon_id, {})
## 获取所有武器
func get_all_weapons() -> Dictionary:
return _registered_weaponsMod 冲突处理
当两个 Mod 修改同一个东西时就会产生冲突。常见的处理策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 最后加载优先 | 后加载的 Mod 覆盖先加载的 | 简单的覆盖场景 |
| 合并 | 把两个 Mod 的修改合并在一起 | 添加型 Mod(都不删除) |
| 用户选择 | 提示用户选择用哪个 Mod 的版本 | 互斥修改 |
| 沙箱隔离 | 每个 Mod 看到的是原始数据的副本 | 安全性要求高 |
防止冲突的最佳实践
- 要求 Mod 使用唯一前缀命名(如
modname_weapon_flame_sword) - 在 manifest 中声明
load_after和conflicts_with - 提供 Mod 兼容性检查工具
- 记录日志,方便排查问题
工坊系统集成思路
如果想让玩家分享和下载 Mod,可以集成一个类似 Steam Workshop 的系统:
实现要点:
- Mod 打包:将 Mod 文件夹打包为
.zip或自定义格式 - 上传接口:HTTP POST 上传到服务器
- 审核机制:自动扫描 + 人工审核,确保安全
- 版本管理:Mod 可以更新,用户可以选择是否更新
- 评分评论:社区评分帮助筛选优质 Mod
本章小结
| 组件 | 职责 | 复杂度 |
|---|---|---|
| manifest.json | 描述 Mod 元信息 | 低 |
| Mod 加载器 | 扫描、解析、排序、加载 | 高 |
| Mod API | 给 Mod 暴露安全接口 | 中 |
| 冲突处理器 | 解决 Mod 之间的冲突 | 高 |
| 工坊系统 | Mod 分享平台 | 高 |
Mod 系统是一个"越早设计越好"的架构决策。如果你的游戏可能有 Mod 社区,在开发初期就规划好 API 边界,比后期改造要轻松得多。
相关章节
- 存档系统:Mod 数据的持久化
- GDExtension:高性能 Mod 的扩展方式
- 资源管理:Mod 资源的加载与卸载
