素材来源:
SOLID原则简介
意大利面条式代码
这种代码指的是一团乱麻,所有行为都高度耦合的代码。比如,在 PlayerController.cs 这个脚本中,玩家的 Move(), Attack(), Jump()…等等行为都写到了一起,此外,还有 PlayAudio 等其他功能。
这样的代码极其脆弱且难以扩展,它就像意大利面一样,由于各个部分都缠绕在一起,你抽取了其中的一根,可能导致多个bug的出现。
解决方案:
抛弃意大利面条式的代码结构,改为模块化结构。
在快速制作demo或者小型游戏时可以使用意大利面条式代码以快速验证效果。
但是如果预计将来项目会越来越大,新引入的游戏物体和功能会越来越多,那么事先建构良好的架构和模块化将是非常重要的。
SOLID原则是什么
总的来说它共有五大原则:
- 单一职责原则 Single Responsibility Principle (SRP)
- 开闭原则 Open-Closed Principle (OCP)
- 里氏替换原则 Liskov Substitution Principle (LSP)
- 接口隔离原则 Interface Segregation Principle (ISP)
- 依赖倒置原则 Dependency Inversion Principle (DIP)
单一职责原则
基本原则
一个脚本,一个功能。SRP将大类的功能拆分成一个个小类,这些小类拥有独立的功能,可以复用且相互之间没有影响。就像乐高的小块积木一样,你可以用它来搭建各种风格各异的大模型。
好处
当每个脚本都有清晰的职责时,你就能随意替换它而不害怕破坏游戏的其他部分。
开闭原则
基本原则
类/模块/函数,对扩展开放、对修改关闭。
好处
通过创建新的类来添加新功能,从来避免破坏原来类的独立性,以此提升代码的模块化。
如何执行
- 为新的功能创建新的类
- 创建接口/抽象类,在具体的功能类中去实现接口/抽象类中的方法
- 用抽象方法去与不同的功能交互,而不是直接调用它们
- 提升多态的应用
里氏替换原则
基本原则
超类/父类物体应该可以被其子类替代而不影响其功能。
好处
允许超类/父类物体可以被其子类替代,而不用直接引用每个子类,这样可以确保系统在引入新的变量时的灵活性和可扩展性。
如何执行
- 创建基类并声明所有的方法
- 给具有相似行为的物体创建为子类
- 在其他需要交互的脚本中,直接引用父类而非子类
接口隔离原则
基本原则
一个类不应该去依赖一个它不会使用的方法。
总结
只写需要的、只执行相关的。
依赖倒置原则
基本原则
高级模块不应该与低级模块耦合,两者都应该依赖于抽象函数。
总结
依赖于抽象函数,而不是具体的实现。
如何执行
- 用接口创建抽象函数
- 通过具体的类来实现接口
- 用接口与具体的类交互
实践项目
我们通过一个2D的坦克飞机大战小游戏来展示在Unity游戏开发中如何实践SOLID原则。
核心玩法:
- 屏幕左侧有两种坦克,一种轻型坦克,玩家可以通过按下“L”来操作它;另一种是重型坦克,通过“H”键来操作
- 轻型坦克可以左右移动、旋转炮管、发射轻型炮弹
- 重型坦克不能移动,但是可以旋转炮管、发射重型炮弹
- 屏幕右侧有可以向左移动的飞机,玩家通过击中它们来增加屏幕上的分数
- 失败条件:如果飞机撞到坦克,坦克受损失活(无法通过按键操作)。两辆坦克均失活时,玩家失败
游戏/功能拆解
内容
背景图片、坦克(轻型和重型)、子弹(轻型和重型)、飞机、UI(分数、开始界面、游戏界面、结束界面)、音效(开火、飞机被击中、坦克被击中)
逻辑
- 游戏开始,按下L键可以激活轻型坦克(移动、旋转、发射炮弹)
- 按下H键可以激活重型坦克(旋转、发射炮弹)
- 飞机从右往左飞,右侧屏幕飞入、左侧屏幕飞出
- 坦克被飞机撞到即失活
- 两家坦克都失活,游戏结束
模块整理
开发步骤
- 场景摆放
- 坦克部分的开发
- 移动和旋转
- 开火
- 实现两种坦克的选择
- 生命值
- 飞机部分的开发
- 飞机预制体
- 飞机的生成
- 生命值
- 游戏管理部分开发
- 配置Game Events
- AudioManager
- UIManager
- GameManager
功能实现及遵循的原则
1. 坦克的移动
在坦克的设计上遵循ISP(接口隔离)和DSP(依赖倒置)。
2. 坦克的开火
在开火时两种弹药的设计上遵循开闭原则(OCP)。我们在每次增加新的种类弹药时应该只做扩展,而不应该对 TankFire.cs 进行修改。在 TankFire.cs 中,应该通过接口而非直接调用相应炮弹的预制体。
此处需要注意的是,由于两种子弹都是经过接口与 TankFire.cs 交互的,但是接口不继承自 MonoBehaviour,无法实例化,所以需要将其隐式转换为 Component 来解决。
|
|
3. 坦克选择器的实现
在这一部分遵循里氏替换(LSP)的原则进行设计。
如果项目比较小的话不用创建单独的
LightEngine.cs和HeavyEngine.cs,全部写在 TankEngine 这个类中也可以。
4. 坦克生命值的实现
遵循依赖倒置原则(DIP),使用一个 IDamageable 的接口来和具体的类交互。这个具体的类可以是飞机、也可以是其他的碰撞物,如子弹、导弹等。
5. 飞机的移动
这部分仿照坦克移动的方式,复用 ImoveUp.cs 的代码,同样采用接口的方式实现,为将来新种类飞机的加入留下空间。
6. 飞机和子弹的自动销毁
我在此处采用统一的脚本 DestroyByTime.cs ,遵循单一职责原则,并未按照课程的方式分别处理。
7. 坦克、子弹、飞机的碰撞检测和生命值联动
这是对第四点的依赖倒置原则的最终实现。由于子弹和飞机都是碰撞后即销毁的情况,所以并没有直接实现 IDamageable接口,在坦克的生命值系统中实现了接口,这样以后即使新增了不同的子弹,对坦克造成不同的伤害,都能轻松实现。在对飞机或者其他类型的敌人中,如果需要设计不同类型的伤害,也可以设计为继承接口的方式灵活实现。
|
|
8. 用观察者模式对UI、音效、游戏状态进行管理
把下面的事件进行注册,这样通过统一的更新接口,观察者们在事件发生变化的时候可以相应被调用。本质是一个监听和响应系统。同时,这样遵循了单一职责原则,在 AudioManager.cs 和 UIManager.cs 等脚本中,只负责播放音效或管理UI的作用。
关于SOLID原则的建议
- 先想简单的解决方案
- 永远不要强迫自己使用SOLID原则,只在需要的时候使用(这也是我的体会)
- 不要过分设计,尤其是你的设计已经超过了项目本身的范围
- 永远试着使用模块化方式,让每个独立的模块像乐高玩具一样可以被重复使用(这是我在其他课程中学到的最有用的方法之一,真的非常省时省力)
- 最初在代码设计上使用SOLID原则肯定会多花时间,但是如果是长远的扩展性很多的项目,这些投入肯定是值得的
- 应用 => 失败 => 学习 => 应用 => 你会得到惊喜!
项目拓展与复盘
拓展
在本项目基础上增加了以下部分:
- 增加中型坦克
- 玩家通过按下“M”键来操作
- 可上下移动,但是移动速度慢于轻型坦克、移动范围小于轻型坦克
- 根据坦克类型给坦克增加不同的子弹:激光、导弹
- 轻型坦克:激光(最少cooldown time),给敌人造成伤害最小,同时发射扇形的5束
- 中型坦克:子弹(中等cooldown time),给敌人造成伤害中等
- 重型坦克:导弹(cooldown time最长),给敌人造成伤害最大
- 增加重型飞机
- 在飞行中有上下随机小范围移动的变化
- 改变飞机的生成规则,让游戏的难度随时间而增加
- 游戏开始,重型飞机生成概率较低,随着时间而升高
- 不同坦克收到伤害程度不同,重型坦克最慢
项目架构总览
项目采用了组件式架构 + 事件驱动 + 策略模式的混合设计。
文件夹结构
|
|
架构总览
| 模块 | 职责 | 核心类 |
|---|---|---|
| Tank/Engine | 坦克引擎切换系统,玩家可通过 L/M/H 键切换不同类型坦克 | TankEngineBase → TankEngineLight/Medium/Heavy,TankEngineSelector |
| Tank/Move | 坦克移动系统,不同坦克有不同移动逻辑 | TankMoveBase → LightTankMove/MediumTankMove/HeavyTankMove,IMoveY,TankRotate |
| Tank/Fire | 射击系统,不同弹药类型通过 IProjectile 接口多态化 |
TankFire,IProjectile → LightShell/MediumShell/RocketShell/LaserShellProjectile |
| Tank/Health | 生命系统,使用 IDamageable 接口实现伤害统一接口 |
HealthSystem,IDamageable |
| Drone | 敌方无人机系统,含生成、移动、受击 | DroneMoveBase → LightDroneMove/HeavyDroneMove,DroneHealth,DroneSpawner |
| Mgr | 全局管理器 | GameMgr(游戏流程)、UIMgr(UI面板/计分)、AudioMgr(音效) |
| GameUtil | 全局事件总线 | GameEvents(静态事件类) |
架构图
代码复用
观察者模式的写法
套路都是一样的,以 AudioManager 为例:
|
|
事件总线系统 — GameEvents
在任何项目中创建类似的静态事件类,用于模块间解耦通信。只需修改事件名称即可适配不同游戏。
|
|
接口驱动的伤害系统— IDamageable
所有需要受伤的对象(玩家、敌人、可破坏物)都实现此接口。攻击方只需调用 GetComponent<IDamageable>()?.TakeDamage(),完全解耦。
|
|
项目中SOLID 原则应用分析
S — 单一职责原则 (Single Responsibility Principle)
坦克的移动、射击、旋转、生命被拆分为独立组件,而非放在一个巨大的 “Tank” 类中。这是 SRP 的典型实践。
| 类 | 职责 |
|---|---|
TankMoveBase |
只负责移动逻辑 |
TankFire |
只负责射击触发 |
TankRotate |
只负责旋转瞄准 |
HealthSystem |
只负责生命值管理 |
AudioMgr |
只负责音效播放 |
UIMgr |
只负责UI显示与计分 |
GameMgr |
只负责游戏流程判定 |
DroneSpawner |
只负责无人机生成 |
DroneHealth |
只负责无人机受击逻辑 |
O — 开闭原则 (Open/Closed Principle)
- 弹药系统:
IProjectile接口使得添加新弹药类型(如LaserShellProjectile、RocketShellProjectile)时,不需要修改TankFire的代码,只需新建一个实现IProjectile的类 - 移动系统:
IMoveY接口 +TankMoveBase允许新增坦克移动方式而不修改基类 - 无人机移动:
DroneMoveBase允许通过继承扩展新的飞行行为(如HeavyDroneMove的随机偏移飞行)
L — 里氏替换原则 (Liskov Substitution Principle)
TankEngineBase的三个子类(TankEngineLight、TankEngineMedium、TankEngineHeavy)可以在TankEngineSelector和GameMgr中被无差别替换使用。GameMgr.CheckEngine()遍历List<TankEngineBase>,调用GetStatus(),不关心具体是哪种引擎DroneMoveBase的子类同理,可以在需要DroneMoveBase的地方互换
I — 接口隔离原则 (Interface Segregation Principle)
IMoveY:只定义MoveY(float)一个方法,不强迫实现者实现不相关的功能(如旋转、射击)IProjectile:只定义Fire(Transform)一个方法,每种弹药只需关注"如何发射"IDamageable:只定义TakeDamage(int)一个方法,任何可受伤对象实现它即可
接口粒度非常小,每个接口只包含一个方法,完美遵循 ISP。
D — 依赖倒置原则 (Dependency Inversion Principle)
DroneHealth中对伤害的处理依赖的是IDamageable接口而非具体的HealthSystem类:
|
|
TankFire依赖IProjectile接口来发射弹药,而非依赖具体的弹药类TankMoveBase通过this as IMoveY将自身转为接口调用,高层逻辑依赖抽象而非具体实现
复盘
SOLID原则扩展性强,但是脚本量也是成倍增加。在最后用观察者模式写UI的时候几乎花了一整天,调试一个bug花了一个下午。对于初学者而言,在使用观察者模式的时候,监听代码放置的位置和其他代码的顺序也很重要,是很容易踩的坑。
另外,这是我第一次系统性地学习游戏的架构方式,在很多时候还是感觉比较抽象的,很多时候很难一下子想到。在做这个项目的时候,最直观的感受是项目扩展性确实很好,无需去老的脚本中改,只需要写新脚本新功能就可以了,也不存在新增一个功能多了三个bug的情况,这一点对大型项目或者是网络手游,真的非常重要。希望能随着我能力的不断提高、对优秀的复杂/大型项目见识的增加和个人项目复杂度的不断增加,提高我对架构的认知和实践能力。