一、问题:那个四万行的 GameMode
不用绕弯,直接说痛点。
你接手过的一个 UE 项目或者之后进组会遇到的情况:
class AMyGameMode : public AGameMode { void InitGame() { // 初始化拍卖规则 // 初始化角色技能 // 初始化藏馆系统 // 初始化匹配系统 // 初始化商城 // 初始化排行榜 // ... // 这个文件已经 4000 行了 }};大型 God Class 是游戏项目的头号技术债。它的形成路径通常是:
第一周:GameMode 管拍卖规则(合理)第一个月:GameMode 管拍卖 + 匹配(勉强)第三个月:GameMode 管拍卖 + 匹配 + 技能 + 商城 + 排行榜 + ... ↑ 已经没人敢动这个类了模块化架构就是来解决这件事的。
二、UE Module 系统——编译期的模块化
1 Module 是 UE 最基础的代码组织单元
每个 Module 是一个独立的编译单元,由 Build.cs 文件定义:
public class MyAuctionGame : ModuleRules { public MyAuctionGame(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "GameplayAbilities", // GAS "GameplayTags", // Gameplay Tag "OnlineSubsystem", // 在线服务 });
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "UMG", // UI }); }}Public vs Private 依赖的区别:
PublicDependency:你的头文件里#include了这个 Module 的东西 → 依赖你的人也需要链接它PrivateDependency:只有你的.cpp内部用了,对外不可见 → 链接更快,更解耦
2 Module 的生命周期
每个 Module 有一个入口类(IModuleInterface 的子类):
class FMyAuctionModule : public IModuleInterface { virtual void StartupModule() override { // Module 加载时调用 // 注册 Asset Types、注册 Console Commands、初始化子系统 UE_LOG(LogTemp, Log, TEXT("拍卖模块已加载")); }
virtual void ShutdownModule() override { // Module 卸载时调用 // 清理、反注册 UE_LOG(LogTemp, Log, TEXT("拍卖模块已卸载")); }};Module 之间的依赖是显式的。一个 Module 不声明依赖另一个,就看不到另一个的任何符号。这迫使你做解耦——如果你在拍卖 Module 里想调商城 Module 的代码,你必须显式声明依赖。
3 你怎么给自己的项目拆 Module
| Module 名 | 职责 | 依赖 |
|---|---|---|
MyAuctionCore | 数据类型、枚举、结构体——纯数据 | Core, CoreUObject |
MyAuctionGameplay | 拍卖规则、状态机、技能 | Core, GameplayAbilities, MyAuctionCore |
MyAuctionUI | HUD、开箱界面、出价面板 | UMG, MyAuctionCore |
MyAuctionCollection | 藏馆 3D 展示 | Engine, MyAuctionCore |
MyAuctionOnline | 匹配、房间管理、网络消息 | OnlineSubsystem, MyAuctionCore |
经验法则:如果一个功能可以独立禁掉(“这个版本不上藏馆”),它就应该是一个 Module。
三、Plugin——运行时的模块化
1 Plugin 和 Module 是什么关系
Plugin (插件) ← 一个包含了一组 Module + 资源的独立包├── Module A ← 一个编译单元├── Module B ← 可以包含多个 Module├── Content/ ← 插件自带的资产(材质、蓝图、UI)├── Resources/ ← 图标、配置文件└── MyPlugin.uplugin ← 插件描述文件MyPlugin.uplugin:
{ "FriendlyName": "拍卖系统", "VersionName": "1.0", "Modules": [ { "Name": "MyAuctionCore", "Type": "Runtime", // Runtime / Editor / Developer "LoadingPhase": "Default" // 什么时候加载 } ]}区别:
| Module | Plugin | |
|---|---|---|
| 粒度 | 一个 .dll/.so | 一组 Module + 资源 |
| 分发 | 通常跟着项目 | 可以独立分发、在不同项目间复用 |
| 版本 | 无独立版本 | 有独立版本号 |
| 资产 | 不包含 | 可以包含 Content 资产 |
2 用 Plugin 做功能拆分(你的竞拍游戏示例)
Plugins/├── AuctionCore/ # 拍卖核心逻辑(数据类型、状态机、技能定义)├── AuctionMatchmaking/ # 匹配系统├── AuctionCollection/ # 藏馆展示├── AuctionStore/ # 商城(皮肤、藏馆装饰)└── AuctionAnalytics/ # 数据埋点好处:
- 多人并行开发:你改藏馆 Plugin,我改匹配 Plugin,互不干扰(只要接口不变)
- 测试隔离:每个 Plugin 可以独立做单元测试甚至独立运行
- 可复用:下次做另一个竞拍游戏,
AuctionCorePlugin 直接复用 - 强制解耦:Plugin 之间的通信必须通过定义好的接口(不然互相看不到符号),保证了架构的干净性
3 网易内部大概率也是这套
大厂的 UE 项目通常用 Plugin 做功能拆分。你的导师让你看 Modular,很可能你们组的项目就是 Plugin 架构——每个系统一个 Plugin,核心框架负责把 Plugin 串起来。
四、组合优于继承——设计模式在 Gameplay 的应用
1 “剑圣继承了勇士,勇士继承了近战单位,近战单位继承了角色…”
这是刚学 UE 时最容易犯的错误——试图用继承层次来表达所有变化维度。结果:
ACharacter └── AMonster ├── AMeleeMonster │ ├── ASwordMonster │ │ ├── AFireSwordMonster ← 来了:冰焰双剑怎么办? │ │ └── AStealthSwordMonster │ └── AAxeMonster └── ARangeMonster每增加一个维度(武器 × 元素 × 行为 AI),类的数量呈指数爆炸。
2 用 Component 替代
UE 的 Component 系统天然支持组合:
// 不用继承表达变化维度class AMonster : public ACharacter { UWeaponComponent* Weapon; // 用 Component 表示"拿什么武器" UElementComponent* Element; // 用 Component 表示"附魔属性" UBehaviorComponent* AIBehavior; // 用 Component 表示"行为模式"};
// 剑圣 = AMonster + SwordWeapon + NoElement + AggressiveAI// 冰焰双剑 = AMonster + DualSwordWeapon + IceFireElement + TacticalAIComponent 带来了两个关键能力:
- 运行时组合:怪物可以在运行时换武器(
SetComponent(NewWeapon)),不是编译时写死的 - 独立开发:武器系统、元素系统、AI 系统各自独立迭代,互不影响
3 Interface——“我想和任何这样做的对象交互”
UE 的 UInterface 可以让你不关心具体类型,只关心”能不能做某事”:
UINTERFACE()class UBiddable : public UInterface { GENERATED_BODY() };
class IBiddable { GENERATED_BODY()public: virtual void PlaceBid(int32 Amount) = 0; virtual int32 GetMaxBid() const = 0;};
// 使用方不需要知道具体类void ProcessBid(TScriptInterface<IBiddable> Bidder) { Bidder->PlaceBid(SomeAmount); // 不关心这到底是个 Player 还是 AI Bot}判断是否该用接口的标准:如果你发现自己写 if (Cast<AHumanPlayer>(Actor)) { ... } else if (Cast<AAIBot>(Actor)) { ... },就该用接口了。
五、GameFeatures——UE5 的模块化 Gameplay 终极方案
1 问题的终极形态
大型游戏的特征:不同模式之间共享大量代码,但又有各自的特殊需求。竞拍游戏里:
基础框架(所有模式共有)├── 单人竞拍模式├── 四人联机竞拍模式├── 团队竞拍模式(2v2)└── 限时活动:万圣节特别竞拍(特殊藏品、特殊技能)GameFeatures Plugin 就是为了解决这个问题的——把”模式特有”的东西打包成独立包,运行时按需加载。
2 怎么工作
GameFeatureAction (定义"激活这个 Feature 时做什么")├── AddComponents → 给特定 Actor 添加 Component├── AddDataRegistry → 注册新的数据表├── AddAbilities → 给玩家添加特定的技能├── AddInputMapping → 添加特殊的按键映射└── SpawnActors → 生成特定的 Actor// 万圣节限时活动的 GameFeatureActionclass UGFE_HalloweenEvent : public UGameFeatureAction { virtual void OnGameFeatureActivating() override { // 1. 替换所有藏品的模型为万圣节版本 // 2. 给所有角色添加 "不给糖就捣蛋" 技能 // 3. 替换开箱音效为恐怖音效 // 4. 加载万圣节限定 UI 主题 }
virtual void OnGameFeatureDeactivating() override { // 活动结束后全部还原 }};关键:万圣节活动的所有代码和资源(模型、贴图、技能定义、UI)都在一个独立的 Plugin 里。活动上线时热加载,活动结束后禁用。不影响基础竞拍代码的一行代码。
3 网易类似的实践
网易的很多 UE 项目自己也有类似的模块化方案——不一定叫 GameFeatures,但思路一致:
- 核心框架 → 定义”功能模块”的加载/卸载接口
- 各个 Gameplay 系统 → 各自实现为功能模块
- 运行时配置 → 决定加载哪些模块(不同模式、不同平台、不同地区)
你的导师让你看 Modular 和 Experience,就是在让你理解这套架构思维——未来项目大概率也是这样组织的。
六、你的竞拍游戏怎么拆:一次设计练习
| 模块名 | 类型 | 职责 | 依赖 |
|---|---|---|---|
AuctionCore | Plugin | 数据类型(拍卖轮次、藏品、角色)、状态机定义 | Core, GameplayAbilities |
AuctionMatch | Plugin | 房间匹配、大厅、准备 | OnlineSubsystem, AuctionCore |
AuctionBidding | Plugin | 出价逻辑、技能效果、信息分发 | AuctionCore, GameplayAbilities |
AuctionCollection | Plugin | 藏馆 3D 展示、藏品旋转查看 | Engine, AuctionCore |
AuctionSeason | GameFeature | 赛季特殊规则(“本周只能用稀有角色”) | AuctionCore |
AuctionUI | Module | 所有 UI Widget | UMG, AuctionCore |
拆出来的直接好处:当策划说”这周改成团队竞拍模式(2v2)“,你需要改的只是换一个 GameMode + 加载一个 AuctionTeamMode Feature,而不是在四万行的 GameMode 里再加 2000 行的 Team 逻辑。
七、总结
| 概念 | 做什么的 | 什么时候用 |
|---|---|---|
| Module | 编译期代码组织 | 代码量超过 5 万行就该考虑拆 |
| Plugin | 可复用的功能包 | 多个项目共享、独立团队维护 |
| Component | 运行时的能力组合 | 避免”剑圣→冰焰双剑”的继承地狱 |
| Interface | 消除类型依赖 | 发现自己在写 Cast 链时 |
| GameFeature | 按需加载的模式/活动 | 限时活动、A/B 测试 |
模块化不是结果——它是一个持续的过程。你现在 mini-game 阶段就该开始练习”这个东西属于哪里”的肌肉记忆。 进组后面对一个几百万行的项目,你不会被类之间的依赖关系吓到——因为你已经习惯了从”这该放进哪个模块”开始思考。