读书笔记:《设计模式与游戏完美开发》
游戏实现中的设计模式
模式一词来源于建筑业,每一种模式都在说明一个一再出现的问题,并描述解决问题的核心,让你能够据以变化,产生出各种招式来解决上万个类似的问题。
设计模式往往满足以下几项要求:
- 解决一再出现的问题
- 解决问题的方案和问题核心的关键点
- 可以重复使用的解决方案
设计模式的六大原则
单一职责原则(SRP: Single Responsibility Principle)
- 当设计封装一个类时,该类应该只负责一件事
- 单个类负责了太多的功能,会导致类难以维护,也不容易了解该类的主要功能,最终导致整个项目过渡依赖于这个类
- 当设计封装一个类时,该类应该只负责一件事
开闭原则(OCP: Open-Closed Principle)
- 对拓展开放,对修改关闭
- Open:增加系统功能的需求时长发生,所以在系统分析时需要朝着“功能接口化”的方向进行设计,将系统功能的“操作方法”向上提升,抽象化为接口,将功能的实现向下移动到子类。这样在面对增加功能需求的时候,就可以通过增加子类来实现。
- 实现方法有重新实现一个新的子类,或者继承旧的实现类
- Closed:对于已经测试完成或已上线运行的功能,不能再修改这个类的任何接口或实现内容
- Open:增加系统功能的需求时长发生,所以在系统分析时需要朝着“功能接口化”的方向进行设计,将系统功能的“操作方法”向上提升,抽象化为接口,将功能的实现向下移动到子类。这样在面对增加功能需求的时候,就可以通过增加子类来实现。
- 对拓展开放,对修改关闭
里氏替换原则(LSP: Liskov Substitution Principle)
- 子类必须能够替换父类
- 按照这个思路去实现的类群组,父类往往是“接口类”或“可被继承的类”
- 客户端在使用的过程中必须不能使用到“对象强制转型为子类”的语句,客户端也不应该知道目前使用的对象是哪个子类实现的
- 子类必须能够替换父类
依赖倒置原则(DIP: Dependence Inversion Principle)
- 高层模块不应该依赖底层模块,两者都应该依赖于抽象概念
- 如同油车分了无铅汽油引擎汽车和柴油引擎汽车,汽车的功能受困于引擎的燃料种类。依赖倒置原则希望将引擎抽象为一个概念,而不希望过早的将引擎与汽车绑定,这样能够提供更多的灵活性。
- 抽象接口不应该依赖于实现,而实现应该依赖于抽象接口
- 当汽车需要动力的时候,只需要调用引擎的接口即可,如果需要更换引擎,只需要从其他的地方拿一个其他的引擎的接口就可以实现简单的更换
- 举例来说,如果一个司机会开奔驰,那么他一定会开宝马。
- 高层模块不应该依赖底层模块,两者都应该依赖于抽象概念
接口隔离原则(ISP: Interface Segregation Principle)
客户端不应依赖它不需要的接口
一个类对另一个类的依赖应建立在最小的接口上
接口隔离原则通过将大接口拆分为多个小接口,确保每个接口的职责单一,避免客户端被迫实现或依赖不需要的方法
- 接口细化:将大接口拆分为多个小接口,每个接口只包含一个特定的功能
- 按需依赖:客户端只依赖它们实际需要的接口,而不是一个包含所有功能的大接口
接口隔离原则的提出是为了解决以下问题
- 胖接口问题:一个接口包含太多方法,导致实现类被迫实现不需要的方法。
- 不必要的耦合:客户端依赖不需要的方法,增加了系统的复杂性和维护成本。
- 灵活性差:大接口难以扩展和修改,影响系统的灵活性。
最少知识原则(LKP: Least Knowledge Principle)
- 一个对象应当对其他对象只有最少的了解
- 只与直接的朋友通信,不与陌生人通信
- 当前对象本身
- 当前对象的成员变量
- 当前对象的方法参数
- 当前对象创建的对象
状态模式
状态模式在 GoF 中的解释是:“让一个对象的行为随着内部状态的改变而变化,而该对象也像是换了个类一样”
使用状态模式的优点:
- 减少错误的发生并降低维护难度:不使用 switch 来判断当前的状态可以减少新增游戏状态时因为未能检查到所有 switch 程序代码而造成的错误
- 状态执行环境单一化:与每一个状态有关的对象及操作都被实现在一个场景状态类下,对于程序员来说,可以更清楚地了解每一个状态执行时所需要的对象及配合的类
- 项目之间可以共享资源:因为每个状态相对独立,所以可以更方便的共享资源
游戏场景的转换 — 状态模式
当游戏比较复杂的时候,通常会设计成多个场景,让玩家在几个场景之间切换。我们可以规划出多个场景,每个场景负责多项功能的执行。
- 登录场景:负责游戏片头、加载游戏数据、出现游戏主画面、等待玩家登录游戏
- 主画面产经:负责进入游戏画面、游戏在主画面中的操作等等
- 战斗场景:负责与玩家组队之后进入副本关卡、挑战Boss等等
游戏场景规划完成后,可以利用状态图将各场景的关系连接起来,并说明它们之间的转换条件以及状态转换的流程。
切分场景的好处
除了可以将游戏功能执行时需要的环境明确分类,还能够方便重复使用。例如每个游戏都有的登录场景,在状态模式的帮助下能够更方便的被其他项目重用。
对于大多数的游戏开发公司来说,登录场景实现的功能,会希望通用于不同的游戏开发羡慕,使其保持流程的一致性。
同时,这样可以各个项目共用的场景独立出来由专门负责开发维护,并同步更新给各个项目。
角色 AI — 状态模式
状态机是游戏程序设计中被应用最频繁的一个模式,而游戏程序员第一次学习有限状态机的场合,多半是在 AI 的实现上。
classDiagram
direction TD
class ICharacter {
+ UpdateAI()
}
class ICharacterAI {
+ Update()
+ ChangeAIState()
}
namespace AIState {
class IAIState {
+ Update()
}
class AttackState {
+ Update()
}
class ChaseState {
+ Update()
}
class IdleState {
+ Update()
}
class MoveState {
+ Update()
}
}
class SoldierAI {
+ ChangeAIState()
}
class EnemyAI {
+ ChangeAIState()
}
IAIState <|-- AttackState
IAIState <|-- ChaseState
IAIState <|-- IdleState
IAIState <|-- MoveState
ICharacterAI o-- IAIState
ICharacter *-- ICharacterAI
ICharacterAI <|-- SoldierAI
ICharacterAI <|-- EnemyAI
外观模式
外观模式在 GoF 中的解释是:“为子系统定义一组统一的接口,这个高级的接口会让子系统更容易被使用。
---
config:
class:
hideEmptyMembersBox: true
---
classDiagram
class client1
class client2
namespace FacadeSystem {
class subsystem1
class subsystem2
class subsystem3
class subsystem4
class subsystem5
class Facade
}
subsystem4 .. subsystem5
client1 .. Facade
client2 .. Facade
Facade .. subsystem1
Facade .. subsystem2
Facade .. subsystem3
Facade .. subsystem4
Facade .. subsystem5
- Client(客户端、用户):从原本需要操作多个子系统的情况,改为只需要面对一个整合后的界面
- SubSystem(子系统):原本会由不同的客户端来操作,改为只会由内部系统之间相互调用
- Facade(统一对外的界面):整合所有子系统的接口及功能,并提供高级界面(或接口)供客户端使用。并在接收客户端的信息后将消息传送给负责的子系统。
外观模式可以让客户端使用简单的界面来操作复杂的系统,并且减少客户端要与之互动的系统数量,让客户端能够专心处理与本身有关的事情。
举例来说,就像是汽车的仪表盘与油门,司机不需要知道仪表盘的数据是怎么来的,也不需要知道油门背后有什么复杂的机械原理,只需要知道仪表盘的数字代表什么意思,油门踩下去能加速就够了。
使用外观模式的优点
- 节省时间:对于某些程序设计语言而言,减少系统之间的耦合度有助益减少系统构建的时间
- 易于分工开发:对于一个既庞大又复杂的子系统而言,应用外观模式,即可成为另一个Facade接口。因此在分工配合上,开发者只需要了解对方负责系统的 Facade 接口类,不必深入了解其中的运行方式
- 增加系统安全性:隔离客户端对子系统的接触除了能减少耦合外,还能增加安全性。因为有时候子系统之间的沟通和构建程序上会有一定的步骤顺序,如果没有 Facade 作为中间层,外部调用者很容易就会让系统初始化失败或者导致宕机。
游戏主要类 — 外观模式
一个程序想要顺利运行,需要同时由内部数个不同的子系统合作完成。这些系统在游戏运行时会彼此使用对方的功能,同时有些子系统必须在游戏开始运行之前按照一定的步骤将它们初始化并设置参数,或者在游戏结束后按照一定的流程释放资源。
如果客户端了解太多游戏内部的沟通方式及流程,那么对于客户端来说就必须与每一个游戏系统绑定,这对客户端来说并不是好事情,会让客户端与每一个子系统都产生依赖关系,增加了游戏系统与客户端的耦合度。
单例模式
单例模式在 GoF 中的解释是:“确认类只有一个对象,并提供一个全局的方法来获取这个对象。”
单例模式的优点
可以限制对象的产生数量,提供方便获取唯一对象的方法
单例模式的缺点
容易造成设计不周和使用过度的问题
反对使用单例模式的原因
单例模式的好处之一是可以马上获取类对象,不必为了“安排对象传递”或“设置引用”而伤脑筋,想使用类对象时,调用类的 Instance 方法就可以马上获得对象,非常方便。但是程序员一旦发现这个“马上获取”的好处时,就很容易在整个项目中滥用单例模式。
获取游戏服务的唯一对象 — 单例模式
在游戏中,涉及到资源管理,音频管理,网络管理等功能,通常都会使用单例模式,避免功能分散。
中介者模式
中介者模式在 GoF 中的解释是:“定义一个接口用来封装一群对象的互动行为。中介者通过移除对象之间的引用,来减少他们之间的耦合度,并且能改变它们之间的互动独立性。”
中介者模式比较类似于中央管理的概念。建立一个信息集中的中心,任何子系统要与其他子系统沟通时,都必须先将请求交给中央单位,再由中央单位分派给对应的子系统。
中介者模式在内部系统的整合上扮演着重要的角色。
以物流业为例,设置一个物品集货中心,让所有收货点的物品必须先集中到集货中心,然后再分配出去,这样各个集货点之间不必知道其他集货点的位置,省去各自在货物运送上的浪费。
中介者模式的优点
- 不会引入太多其他的系统:每个游戏系统和玩家界面,除了会引用与自身功能相关的类外,无论是信息的获取还是信息的传递都通过中介者对象来完成,这使得每一个游戏系统、玩家界面对外的依赖度缩小到只有一个类。
- 系统被依赖的程度降低了:每一个游戏系统或玩家界面只在中介者类的方法中被调用,所以当游戏系统或玩家界面有所变动时,受影响的也只有中介者类,可以降低系统维护的难度。
中介者模式的缺点
由于各个系统和玩家界面都需要通过中介者来进行信息交换和沟通,因此中介者很容易出现操作接口爆炸的情况。需要搭配其他设计模式来避免这种情况的发生。
游戏内各系统的整合 — 中介者模式
整个游戏系统在面对客户端的时候,可以使用外观模式整合出一个高级的界面供客户端使用,减少它们接触游戏系统的运行,并加强安全性及减少耦合度。但对于内部系统而言,就需要中介者模式介入。
例如:
兵营界面接到玩家指令,向兵营系统发出要训练一名展示的需求
兵营系统收到通知后,向行动力系统询问是否有足够的行动力
行动力系统回复有足够行动力后,兵营系统执行产生战士的功能
兵营系统开始执行生产,然后通知行动力系统扣除行动力,接着通知游戏状态界面显示当前行动力
最后将产生的战士交给角色管理系统进行管理
系统切分越细,意味着系统之间的沟通越复杂,如果系统内部持续存在这样的连接,就会产生以下缺点
- 单一系统引入了太多其他系统的功能,不利于单一系统的转换和维护
- 单一系统被过多的系统依赖,不利于接口的更改
- 因为需要提供给其他系统操作,系统的接口容易过于庞大,不容易维护
游戏的主循环 — GameLoop
Game Loop 是游戏软件与一般应用软件的一个很重要的区别。
一般应用软件一般是指 Word、Excel 等应用软件,它们的特色是程序启动后会等待用户去操作它,给它命令,以被动的方式等待用户决定要执行的功能。也因此,一般应用软件往往是以“事件驱动”的方式来设计,屏幕显示画面上会有很多的按钮,菜单等组建,等待用户对其进行点击以产生事件,从而让应用软件执行后续的功能。
但游戏软件是要去模拟一个虚拟的世界,这个世界有自己的规则,玩家只是扮演其中一个会移动的角色,并通过输入设备与这个世界互动。因此游戏软件在设计的时候必须提供一个机制让这个世界能够不断地更新,让其能自动产生各种情景与玩家互动。
游戏软件与一般应用软件的另一个区别是游戏软件需要不断地进行画面更新,当玩家进入游戏世界赞叹画面美丽、动态逼真时,游戏软件正在不断地进行画面更新以产生动画的效果。而用于游戏性能评测的每秒帧数(FPS: Frame Per Second)通常就是指游戏系统在一秒钟之内能执行多少次画面更新。
flowchart LR
玩家操作 --> 游戏逻辑更新 --> 画面更新 --> 玩家操作
我们可以用简单的代码来描述 Game Loop 的实现。
1int main() {
2 GameInit();
3
4 while (NeedRun) {
5 UserInput();
6 UpdateGameLogic();
7 Render();
8 }
9
10 GameRelease();
11}
桥接模式
桥接模式在 GoF 中的解释是:“将抽象与实现分离,使二者可以独立的变化。”
多数人会以为这是“只依赖接口而不依赖实现”原则的另一个解释:“定义一个接口类,然后将实现放在子类中完成。”
然而,桥接模式的真正目的是解耦抽象与实现,使得二者能够独立变化,避免因实现变化而影响抽象层,或者因抽象层变化而影响实现层。
假设我们需要实现一个3D绘画工具,并需要支持常见的 OpenGL 与 DirectX 的 API。
SphereGL 代表使用 OpenGL 绘制球体,SphereDX 代表使用 DirectX 绘制球体,因为满足“只依赖接口而不依赖实现”的原则,所以客户端只需要知道 IShpere 接口,至于由哪一个实现类负责完成所需功能,则交给系统决定。
classDiagram
class ISphere {
+ Draw()
}
class SphereGL {
- m_OpenGL
+ Draw()
}
class SphereDX {
- m_DirectX
+ Draw()
}
class OpenGL {
+ GLRender()
}
class DirectX {
+ GLRender()
}
ISphere <|-- SphereGL
ISphere <|-- SphereDX
SphereGL o-- OpenGL
SphereDX o-- DirectX
粗看起来没有什么问题,然而,这样的架构接着开发下去,迟早会变得越来越混乱。
例如,我们在当前的架构上加入两个新的图形,再加上一个新的渲染接口。
classDiagram
direction TD
class IShape {
+ Draw()
}
class ISphere {
+ Draw()
}
class SphereGL {
- m_OpenGL
+ Draw()
}
class SphereDX {
- m_DirectX
+ Draw()
}
class SphereGLES {
- m_OpenGLES
+ Draw()
}
class ICube {
+ Draw()
}
class CubeGL {
- m_OpenGL
+ Draw()
}
class CubeDX {
- m_DirectX
+ Draw()
}
class CubeGLES {
- m_OpenGLES
+ Draw()
}
class ICylinder {
+ Draw()
}
class CylinderGL {
- m_OpenGL
+ Draw()
}
class CylinderDX {
- m_DirectX
+ Draw()
}
class CylinderGLES {
- m_OpenGLES
+ Draw()
}
class OpenGL {
+ GLRender()
}
class DirectX {
+ GLRender()
}
class OpenGLES {
+ GLESRender()
}
IShape <|-- ISphere
IShape <|-- ICube
IShape <|-- ICylinder
ISphere <|-- SphereGL
ISphere <|-- SphereDX
ISphere <|-- SphereGLES
ICube <|-- CubeGL
ICube <|-- CubeDX
ICube <|-- CubeGLES
ICylinder <|-- CylinderGL
ICylinder <|-- CylinderDX
ICylinder <|-- CylinderGLES
SphereGL o-- OpenGL
SphereDX o-- DirectX
SphereGLES o-- OpenGLES
CubeGL o-- OpenGL
CubeDX o-- DirectX
CubeGLES o-- OpenGLES
CylinderGL o-- OpenGL
CylinderDX o-- DirectX
CylinderGLES o-- OpenGLES
我们确实将实现“不同的功能”交给“不同的子类”来完成,也就是利用“继承的方法”来完成“不同的功能实现”,但在上面的案例中我们可以看到,这种解法大大增加了系统的维护难度,我们每增加一个形状,就需要连带增加两个实现类。
为了避免继承体系的爆炸式增长,桥接模式应运而生。桥接模式将抽象部分与实现部分分离,让它们独立变化,从而避免了继承带来的复杂性。。
在桥接模式中,抽象层和实现层通过接口进行解耦,使得我们可以单独扩展抽象层和实现层,而不必在每次变化时修改两者的代码。
我们将“形状”(如球体、立方体等)和“渲染方式”(如 OpenGL、DirectX 等)分离开来,改为通过桥接的方式,将抽象与实现的绑定关系放在运行时进行。下面是改进后的类图:
classDiagram
class IShape {
+ Draw()
}
class Sphere {
- renderer: IRenderer
+ Draw()
}
class Cube {
- renderer: IRenderer
+ Draw()
}
class Cylinder {
- renderer: IRenderer
+ Draw()
}
class IRenderer {
+ RenderShape()
}
class OpenGLRenderer {
+ RenderShape()
}
class DirectXRenderer {
+ RenderShape()
}
class OpenGLESRenderer {
+ RenderShape()
}
IShape <|-- Sphere
IShape <|-- Cube
IShape <|-- Cylinder
Sphere o-- IRenderer
Cube o-- IRenderer
Cylinder o-- IRenderer
IRenderer <|-- OpenGLRenderer
IRenderer <|-- DirectXRenderer
IRenderer <|-- OpenGLESRenderer
- 减少了类的数量:通过桥接模式,我们将“形状”和“渲染方式”分开,只需要在抽象类和实现类之间建立关联,而不是创建大量的子类。
- 增强了系统的灵活性:新的形状或渲染方式可以通过扩展相应的接口来添加,而不需要修改现有代码,符合开闭原则。
- 独立变化:形状和渲染方式可以独立变化,例如,我们可以增加新的图形类型或渲染API,而不需要同时修改多个地方。
角色与武器的实现 — 桥接模式
classDiagram
direction LR
namespace Character {
class ICharacter {
# m_Weapon
# WeaponAttackTarget(Character Target)
+ Attack(ICharacter Target)
}
class ISoldier {
+ Attack(ICharacter Target)
}
class IEnemy {
+ Attack(ICharacter Target)
}
}
namespace Weapon {
class IWeapon {
+Fire(ICharacter Target)
}
class WeaponGun {
+Fire(ICharacter Target)
}
class WeaponRifle {
+Fire(ICharacter Target)
}
class WeaponRocket {
+Fire(ICharacter Target)
}
}
ICharacter <|-- ISoldier
ICharacter <|-- IEnemy
IWeapon <|-- WeaponGun
IWeapon <|-- WeaponRifle
IWeapon <|-- WeaponRocket
ICharacter o-- IWeapon
策略模式
策略模式在 GoF 中的解释是:“定义一组算法,并封装每个算法,让他们可以彼此交换使用。策略模式让这些算法在客户端使用它们时能更加独立。”
策略包含许多东西,拿购物软件举例:
- 当购买商品满399元,加送100元折扣券
- 当购买商品满699元,加送200元折扣券
- 当购买商品满40000元,加送一部XX手机
这些不同的计算方式就是所谓的算法,而这些算法中的每一个都应该独立出来,将计算细节加以封装隐藏,让他们成为一个算法类群组。客户端只需要根据情况来选择对应的算法类即可,至于计算的方式及规则,客户端不需要去理会。
classDiagram
class Client {
+ ContentInterface()
}
class Strategy {
+ AlgoInterface()
}
class ConcreteStrategyA {
+ AlgoInterface()
}
class ConcreteStrategyB {
+ AlgoInterface()
}
class ConcreteStrategyC {
+ AlgoInterface()
}
Client o-- Strategy
Strategy <|-- ConcreteStrategyA
Strategy <|-- ConcreteStrategyB
Strategy <|-- ConcreteStrategyC
- Strategy(策略接口类):提供“策略客户端”可以使用的方法
- ConcreteStrategy(策略实现类):不同的算法实现
- Client(策略客户端):拥有一个 Strategy 类的对象引用,并通过对象引用获取想要的计算结果
状态模式和策略模式有一些相像之处,它们都被 GoF 归类在行为模式分类下,都是由一个客户端来维护对象引用,并借此调用提供功能的方法。
差异在于以下两点:
- State 是在一群状态中切换、状态和状态之间有对应和连接的关系。而Strategy 则时由一群没有任何关系的类组成,不知道彼此的存在
- State 受限于状态机的切换规则,在设计初期就会定义所有可能的状态,就算后期追加业需要和现有的状态有所关联,而不是想加入就加入。Strategy 是由封装计算算法而形成的一种设计模式,算法之间不存在任何依赖关系,有新增的算法就可以马上加入或替换。
角色属性的计算 — 策略模式
当实现每一个策略类的计算公式时,可能需要外界提供相关的信息作为计算依据,所以调用策略的时候需要传入角色对象来作为决策依据。
模板方法模式
模板方法模式在 GoF 中的解释是:“在一个操作方法中定义算法的流程,其中某些步骤由子类完成。模板方法让子类在不变更原有算法流程的情况下,还能重新定义其中的步骤。”
从定义上看,模板方法模式包含以下两个概念:
- 定义一个算法的流程,即是很明确地定义算法的每个步骤,并写在父类的方法中,而每一个步骤都可以是一个方法的调用。
- 某些步骤由子类完成,为什么父类不自己完成,却要由子类去实现呢?
- 定义算法的流程中,某些步骤需要由执行时“当下的环境”来决定
- 定义算法时,针对每一个步骤都提供了预设的解决方案,但是有时候会出现“更好的解决方法”,此时就要让这个更好的解决方法能够在原有的架构中被使用。
攻击特效与击中反应 — 模板方法模式
如果某个武器的子弹特效和声音特效需要修改,直接重写相应的方法即可。
classDiagram
class IWeapon {
+ Fire()
# DoShowBulletEffect()
# DoShowSoundEffect()
}
class WeaponGun {
+ Fire()
# DoShowBulletEffect()
# DoShowSoundEffect()
}
class WeaponRifle {
+ Fire()
# DoShowBulletEffect()
# DoShowSoundEffect()
}
class WeaponRocket {
+ Fire()
# DoShowBulletEffect()
# DoShowSoundEffect()
}
IWeapon <|-- WeaponGun
IWeapon <|-- WeaponRifle
IWeapon <|-- WeaponRocket
工厂方法模式
工厂方法模式在 GoF 中的解释是:“定义一个可以产生对象的接口,但是让子类决定要产生哪一个类的对象。工厂方法模式让类的实例化程序延迟到子类中实施。”
工厂方法模式就是将类“产生对象的流程”集合管理的模式,其好处如下:
- 能针对对象产生的流程制定规则
- 减少客户端参与对象生产的过程,尤其是对那种类对象生产过程过于复杂的。如果让客户端直接操作,会导致客户端与该类耦合度变高,不利于后续维护。
建造者模式
建造者模式在 GoF 中的解释是:“将一个复杂对象的构建过程与它的对象表现分离出来,让相同的构建流程可以产生不同的对象行为表现。”
就像是汽车装配厂无论车子的喷漆、座椅、引擎如果不同,在装配的时候都会按照相同的步骤进行装配。
建造者模式可以分为两个步骤来实施:
- 将复杂的构建流程独立出来,并将整个流程分为几个步骤,其中的每一个步骤可以是一个功能组建的设置,也可以是参数的指定,并且在一个构建方法中,将这些步骤串接起来。
- 定义一个专门实现这些步骤的实现者,这些实现者知道每一步该如何完成,并且能够接收参数来决定要产出的功能,但不知道整个组装流程是什么。
角色的组装 — 建造者模式
classDiagram
direction LR
class CharacterFactory {
+ CharacterBuilderSystem
+ CreateSoldier()
+ CreateEnemy()
}
namespace BuildPattern {
class CharacterBuilderSystem {
+ Construct(ICharacterBuilder builder)
}
class ICharacterBuilder {
+ SetBuildParam()
+ LoadAsset()
+ AddOnClickScript()
+ AddWeapon()
+ SetCharacterValue()
+ AddCharacterSystem()
}
class SoldierBuilder {
+ SetBuildParam()
+ LoadAsset()
+ AddOnClickScript()
+ AddWeapon()
+ SetCharacterValue()
+ AddCharacterSystem()
}
class EnemyBuilder {
+ SetBuildParam()
+ LoadAsset()
+ AddOnClickScript()
+ AddWeapon()
+ SetCharacterValue()
+ AddCharacterSystem()
}
}
CharacterFactory --> CharacterBuilderSystem
CharacterBuilderSystem ..> ICharacterBuilder
ICharacterBuilder <|-- SoldierBuilder
ICharacterBuilder <|-- EnemyBuilder
享元模式
享元模式在 GoF 中的解释是:“使用共享的方式,让一大群小规模对象能更有效地运行。”
这里有两个重点,共享 与 一大群小规模对象。
- 一大群小对象是指虽然有时候类的组成很简单,可能只有几个 int,但是如果这些类成员的属性是相同且可以共享的,那么当系统构建了一大群类的对象时,这些重复的部分就都是浪费的,因为它们实际上只需要存在一份即可。
- 共享指使用管理结构来设计信息的存取方式,让可以被共享的信息只需要产生一份对象,而这个对象能够被引用到其他对象中。
这里需要注意的是,因为信息被多个对象共享,因此对于共享信息的修改就必须加以限制,否则很容易导致其他引用对象的错误。
对象中那些只能读取而不能写入的共享部分被称为“内在状态”,对象中不能被共享的部分则称为“外在状态”。
游戏属性管理功能 — 享元模式
在游戏设计中,例如角色最大生命值、最大移动速度等都是不会可以共用的外在状态。
组合模式
组合模式在 GoF 中的解释是:“将对象以树状结构组合,用以表现部分-全体的层次关系。组合模式让客户端在操作各个对象或组合对象时是一致的。”
GoF 的组合模式中说明,它使用树状结构来组合各个对象,所以实现时包含了根节点和叶节点的概念。而根节点中会包含叶节点的对象,当根节点被删除时,叶节点会被一起删除。
组合模式(Composite Pattern)在游戏开发中非常常见,主要用于处理对象的层次结构,特别是当单个对象和组合对象的操作方式相同时,可以统一管理个体对象和组合对象。
Unity3D 的界面设计 — 组合模式
Unity 的 UI 面板就是一个经典的组合模式。
命令模式
命令模式在 GoF 中的解释是:“将请求封装成为对象,让开发者可以将客户端的不同请求参数化,并配合队列、记录、复原等方法来执行请求的操作。”
定义式可以分为两部分:
- 请求的封装:请求简单的说就是某个客户端组建想要调用某个功能,而这个功能被实现在某个类中。一般而言最直接的方式时通过直接调用该类对象的方法,但如果调用一个请求需要传入许多参数,为了方便起见会建议将这些参数上的设置以一个类的形式加以封装。调用方法时,直接传入该类对象即可。
- 请求的操作:当请求可以被封装成一个对象时,那么这个请求就可以被操作。例如存储、记录、复原等。
责任链模式
责任链模式在 GoF 中的解释是:“让一群对象都有机会来处理一项请求,以减少请求发送者与接收者之间的耦合度。将所有接收者对象串联起来,让请求沿着串接传递,直到有一个对象可以处理为止。”
classDiagram
class Client
namespace HandlerChain {
class Handler {
+ HandleRequest()
}
class ConcreteHandlerA {
+ HandleRequest()
}
class ConcreteHandlerB {
+ HandleRequest()
}
}
Client --> Handler
Handler --> Handler
Handler <|-- ConcreteHandlerA
Handler <|-- ConcreteHandlerB
GoF 参与者说明:
- Client
- 将请求发给第一个接收者对象,并等待请求的回复
- Handler(请求处理接口)
- 定义可以处理客户端请求事项的接口
- 可包含“可链接下一个同样能处理请求”的对象引用
- ConcreteHandler(实现请求接收者接口)
- 实现请求处理接口,并判断对象本身是否能处理这次的请求
- 不能完成请求的话,交由后继者处理
观察者模式
观察者模式在 GoF 中的解释是:“在对象之间定义一个一对多的连接方法,当一个对象变换状态时,其他关联的对象都会自动收到通知。”
社交网络的发布者与粉丝的关系是一个很典型的关注者模式。当我们点击关注之后,关注对象有任何的动态我们都可以第一时间得知。
成就系统 — 观察者模式
成就系统需要对所有需要纳入统计的事件进行订阅操作,等游戏事件系统发布事件时,成就系统就去获取所需的信息来进行次数累计并判断成就项目是否完成。
classDiagram
direction LR
class GameEventSystem {
+ Register(GameEvent, Observer)
+ NotifyGameEvent(GameEvent, Param)
}
namespace Subject {
class IGameEventSubject {
+ Attach(Observer)
+ Detach(Observer)
+ Notify()
+ SetParam()
}
class EnemyKilledSubject {
+ SetParam()
+ GetKillCount()
}
}
namespace GameObserver {
class IGameEventObserver {
+ Update()
+ SetSubject()s
}
class EnemyKilledObserverUI {
+ Update()
+ SetSubject()
}
class EnemyKilledObserverCaptiveCamp {
+ Update()
+ SetSubject()
}
class EnemyKilledObserverStageScore {
+ Update()
+ SetSubject()
}
class EnemyKilledObserverAchievement {
+ Update()
+ SetSubject()
}
}
GameEventSystem *-- IGameEventSubject
IGameEventSubject <|-- EnemyKilledSubject
IGameEventSubject *-- IGameEventObserver
IGameEventObserver <|-- EnemyKilledObserverUI
IGameEventObserver <|-- EnemyKilledObserverCaptiveCamp
IGameEventObserver <|-- EnemyKilledObserverStageScore
IGameEventObserver <|-- EnemyKilledObserverAchievement
EnemyKilledSubject <-- EnemyKilledObserverCaptiveCamp : Get Kill Count
EnemyKilledSubject <-- EnemyKilledObserverStageScore : Get Kill Count
EnemyKilledSubject <-- EnemyKilledObserverAchievement : Get Kill Count
备忘录模式
备忘录模式在 GoF 中的解释是:“在不违反封装的原则下,获取一个对象的内部状态并保留在外部,让该对象可以在日后恢复到原先保留时的状态。”
常见的做法是系统本身主动提供内部信息,并主动向存盘功能提供与自己系统有关的信息。
这与系统本身提供存取内部成员方法的不同在于,系统提供存取内部成员方法是让系统处于被动状态。
备忘录模式的概念时让有记录保存需求的类自行产生要保存的数据,外界完全不用了解这些记录产生的过程及来源。另外也让类自己从之前保存的数据中找回信息,自行重设类的状态。
访问者模式
访问者模式在 GoF 中的解释是:“定义一个能够在一个对象接口中对于所有元素执行的操作。访问者让开发者可以定义一个新的操作,而不必更改到被操作元素的类接口。”
访问者模式是一种行为设计模式,它允许你在不改变类的前提下,向已有的类层次结构中添加新的操作。它将算法与对象结构分离,使得可以在不修改对象的情况下定义新的行为。
访问者模式的优点
- 符合开闭原则:可以在不修改已有类的情况下添加新功能。
- 分离算法与数据结构:适用于复杂的层次结构。
- 提高可读性和可维护性:让数据和操作解耦,代码更清晰。
访问者模式的缺点
- 破坏封装性:访问者需要访问
Element
的内部数据。 - 复杂性增加:当元素类型较多时,访问者的
Visit
方法会变得冗长。 - 双重分派(Double Dispatch):导致代码执行效率略低。
物品系统 — 访问者模式
classDiagram
direction LR
class IItemVisitor {
+ Visit(Weapon weapon)
+ Visit(Potion potion)
}
class UseItemVisitor {
+ Visit(Weapon weapon)
+ Visit(Potion potion)
}
class SellItemVisitor {
+ Visit(Weapon weapon)
+ Visit(Potion potion)
}
class Item {
+ string Name
+ Accept(IItemVisitor visitor)
}
class Weapon {
+ int AttackPower
+ Accept(IItemVisitor visitor)
}
class Potion {
+ int HealAmount
+ Accept(IItemVisitor visitor)
}
class Player {
+ List~Item~ inventory
+ UseItems()
+ SellItems()
}
IItemVisitor <|.. UseItemVisitor
IItemVisitor <|.. SellItemVisitor
Item <|-- Weapon
Item <|-- Potion
Weapon --> IItemVisitor : Accept(visitor)
Potion --> IItemVisitor : Accept(visitor)
Player --> Item : owns
1using System;
2using System.Collections.Generic;
3
4// 访问者接口
5public interface IItemVisitor
6{
7 void Visit(Weapon weapon);
8 void Visit(Potion potion);
9}
10
11// 物品接口
12public abstract class Item
13{
14 public string Name { get; set; }
15 public abstract void Accept(IItemVisitor visitor);
16}
17
18// 武器类
19public class Weapon : Item
20{
21 public int AttackPower { get; set; }
22 public override void Accept(IItemVisitor visitor) => visitor.Visit(this);
23}
24
25// 药水类
26public class Potion : Item
27{
28 public int HealAmount { get; set; }
29 public override void Accept(IItemVisitor visitor) => visitor.Visit(this);
30}
31
32// 具体访问者:使用物品
33public class UseItemVisitor : IItemVisitor
34{
35 public void Visit(Weapon weapon)
36 {
37 Console.WriteLine($"装备武器: {weapon.Name},攻击力: {weapon.AttackPower}");
38 }
39
40 public void Visit(Potion potion)
41 {
42 Console.WriteLine($"使用药水: {potion.Name},恢复 {potion.HealAmount} 点生命值");
43 }
44}
45
46// 具体访问者:出售物品
47public class SellItemVisitor : IItemVisitor
48{
49 public void Visit(Weapon weapon)
50 {
51 Console.WriteLine($"出售武器: {weapon.Name},获得金币: {weapon.AttackPower * 10}");
52 }
53
54 public void Visit(Potion potion)
55 {
56 Console.WriteLine($"出售药水: {potion.Name},获得金币: {potion.HealAmount * 5}");
57 }
58}
59
60// 测试
61class Program
62{
63 static void Main()
64 {
65 List<Item> inventory = new List<Item>
66 {
67 new Weapon { Name = "剑", AttackPower = 50 },
68 new Potion { Name = "生命药水", HealAmount = 20 }
69 };
70
71 var useVisitor = new UseItemVisitor();
72 var sellVisitor = new SellItemVisitor();
73
74 Console.WriteLine("玩家使用物品:");
75 foreach (var item in inventory) item.Accept(useVisitor);
76
77 Console.WriteLine("\n玩家出售物品:");
78 foreach (var item in inventory) item.Accept(sellVisitor);
79 }
80}
装饰模式
装饰模式在 GoF 中的解释是:“动态地附加额外的责任给一个对象。装饰模式提供了一个灵活的选择,让子类可以用来扩展功能。”
装饰模式是一种结构型设计模式,它允许在不修改现有类的基础上动态地为对象添加新的功能。它通过创建一系列装饰器(Decorator)类来包装原始对象,每个装饰器类都可以扩展对象的行为。
装饰模式的优点
- 符合开闭原则:可以动态地添加功能,而不修改原始类。
- 灵活的功能组合:可以随意叠加 Buff、附魔等效果。
- 减少类爆炸:相比于继承,每种组合不需要创建新的子类。
装饰模式的缺点
- 增加了对象数量:每个装饰都会创建一个新的包装对象,可能影响性能。
- 调试较复杂:多层装饰器嵌套时,可能难以追踪最终行为。
角色 Buff 系统 — 装饰模式
在 RPG 游戏中,角色可能会获得增益效果(Buff),比如增加攻击力、护甲或移动速度。使用装饰模式可以灵活地为角色动态添加这些 Buff,而无需修改角色类本身。
1using System;
2
3// 1. 组件接口
4public interface ICharacter
5{
6 void ShowStats();
7}
8
9// 2. 具体角色
10public class Player : ICharacter
11{
12 public int AttackPower { get; set; } = 10;
13
14 public void ShowStats()
15 {
16 Console.WriteLine($"基础攻击力: {AttackPower}");
17 }
18}
19
20// 3. 装饰器基类
21public abstract class CharacterDecorator : ICharacter
22{
23 protected ICharacter character;
24
25 public CharacterDecorator(ICharacter character)
26 {
27 this.character = character;
28 }
29
30 public virtual void ShowStats()
31 {
32 character.ShowStats();
33 }
34}
35
36// 4. 具体装饰器 - 力量 Buff
37public class StrengthBuff : CharacterDecorator
38{
39 public StrengthBuff(ICharacter character) : base(character) { }
40
41 public override void ShowStats()
42 {
43 base.ShowStats();
44 Console.WriteLine("➕ 力量 Buff: +5 攻击力");
45 }
46}
47
48// 5. 具体装饰器 - 速度 Buff
49public class SpeedBuff : CharacterDecorator
50{
51 public SpeedBuff(ICharacter character) : base(character) { }
52
53 public override void ShowStats()
54 {
55 base.ShowStats();
56 Console.WriteLine("➕ 速度 Buff: +10% 移动速度");
57 }
58}
59
60// 6. 测试
61class Program
62{
63 static void Main()
64 {
65 ICharacter player = new Player();
66 Console.WriteLine("🏆 原始角色状态:");
67 player.ShowStats();
68
69 // 添加力量 Buff
70 player = new StrengthBuff(player);
71 Console.WriteLine("\n💪 角色获得力量 Buff:");
72 player.ShowStats();
73
74 // 再添加速度 Buff
75 player = new SpeedBuff(player);
76 Console.WriteLine("\n⚡ 角色获得速度 Buff:");
77 player.ShowStats();
78 }
79}
classDiagram
direction TB
class ICharacter {
+ void ShowStats()
}
class Player {
+ int AttackPower
+ void ShowStats()
}
class CharacterDecorator {
+ ICharacter character
+ void ShowStats()
}
class StrengthBuff {
+ void ShowStats()
}
class SpeedBuff {
+ void ShowStats()
}
ICharacter <|.. Player
ICharacter <|.. CharacterDecorator
CharacterDecorator <|-- StrengthBuff
CharacterDecorator <|-- SpeedBuff
CharacterDecorator --> ICharacter : wraps
武器强化系统 — 装饰模式
在游戏中,玩家可能会升级武器,例如附加火焰伤害、冰冻效果、暴击加成等。装饰模式可以让武器的能力动态变化,而不需要修改原始武器类。
1// 1. 武器接口
2public interface IWeapon
3{
4 void Attack();
5}
6
7// 2. 基础武器
8public class Sword : IWeapon
9{
10 public void Attack()
11 {
12 Console.WriteLine("⚔️ 普通剑攻击");
13 }
14}
15
16// 3. 武器装饰器
17public abstract class WeaponDecorator : IWeapon
18{
19 protected IWeapon weapon;
20
21 public WeaponDecorator(IWeapon weapon)
22 {
23 this.weapon = weapon;
24 }
25
26 public virtual void Attack()
27 {
28 weapon.Attack();
29 }
30}
31
32// 4. 具体装饰器 - 火焰附魔
33public class FireEnchantment : WeaponDecorator
34{
35 public FireEnchantment(IWeapon weapon) : base(weapon) { }
36
37 public override void Attack()
38 {
39 base.Attack();
40 Console.WriteLine("🔥 造成额外火焰伤害!");
41 }
42}
43
44// 5. 具体装饰器 - 冰冻附魔
45public class IceEnchantment : WeaponDecorator
46{
47 public IceEnchantment(IWeapon weapon) : base(weapon) { }
48
49 public override void Attack()
50 {
51 base.Attack();
52 Console.WriteLine("❄️ 造成额外冰冻效果!");
53 }
54}
55
56// 6. 测试
57class Program
58{
59 static void Main()
60 {
61 IWeapon sword = new Sword();
62 Console.WriteLine("🗡️ 初始武器攻击:");
63 sword.Attack();
64
65 // 添加火焰附魔
66 sword = new FireEnchantment(sword);
67 Console.WriteLine("\n🔥 添加火焰附魔:");
68 sword.Attack();
69
70 // 再添加冰冻附魔
71 sword = new IceEnchantment(sword);
72 Console.WriteLine("\n❄️ 添加冰冻附魔:");
73 sword.Attack();
74 }
75}
classDiagram
direction TB
class IWeapon {
+ void Attack()
}
class Sword {
+ void Attack()
}
class WeaponDecorator {
+ IWeapon weapon
+ void Attack()
}
class FireEnchantment {
+ void Attack()
}
class IceEnchantment {
+ void Attack()
}
IWeapon <|.. Sword
IWeapon <|.. WeaponDecorator
WeaponDecorator <|-- FireEnchantment
WeaponDecorator <|-- IceEnchantment
WeaponDecorator --> IWeapon : wraps
适配器模式
适配器模式在 GoF 中的解释是:“将一个类的接口转换成为客户端期待的类的接口。适配器模式让原本接口不兼容的类能一起合作。”
就像是不同的国家之间插口的规范往往不一样,需要一个适配器作为中间层。
classDiagram
direction LR
class Client
class Target
class Adapter {
+ Request()
}
class Adaptee {
+ SpecificRequest()
}
Client --> Target
Target <|-- Adapter
Adaptee *-- Adapter
GoF 参与者的说明如下。
- Client(客户端):用户端预期使用的是 Target 目标接口的对象
- Target(目标接口):定义给客户端使用的接口
- Adaptee(被转换类):与客户端预期接口不同的类
- Adapter(适配器)
- 继承自 Target 目标接口,让客户端可以操作
- 包含 Adaptee,可以设置为引用或者组合
- 实现 Target 的接口方法 Request 时,应调用适当的 Adaptee 方法来完成实现
适配器模式的优点
- 兼容旧系统:可以让新旧代码协同工作,而不需要大规模修改旧代码。
- 灵活性:能够适配不同的第三方 API、输入设备等。
- 遵循开闭原则:不修改原有代码,而是通过适配器扩展功能。
适配器模式的缺点
- 可能影响性能:如果适配逻辑复杂,可能会增加额外的运行成本。
- 代码可读性降低:如果有多个适配器,可能会增加理解成本。
游戏输入适配
假设我们的游戏最初只支持键盘输入 (KeyboardInput),现在想增加对手柄输入 (GamepadInput) 的支持,但不想修改大量代码。适配器模式可以让手柄输入与键盘输入保持相同的 IInputHandler 接口。
1// 1. 目标接口(统一的输入接口)
2public interface IInputHandler
3{
4 void HandleInput();
5}
6
7// 2. 具体实现:键盘输入
8public class KeyboardInput : IInputHandler
9{
10 public void HandleInput()
11 {
12 Console.WriteLine("⌨️ 处理键盘输入");
13 }
14}
15
16// 3. 具体实现:手柄输入(不兼容 IInputHandler)
17public class GamepadInput
18{
19 public void ReadJoystick()
20 {
21 Console.WriteLine("🎮 读取手柄摇杆输入");
22 }
23}
24
25// 4. 适配器:让手柄输入适配 IInputHandler
26public class GamepadAdapter : IInputHandler
27{
28 private GamepadInput gamepad;
29
30 public GamepadAdapter(GamepadInput gamepad)
31 {
32 this.gamepad = gamepad;
33 }
34
35 public void HandleInput()
36 {
37 gamepad.ReadJoystick(); // 将 ReadJoystick() 适配为 HandleInput()
38 }
39}
40
41// 5. 游戏逻辑
42class Game
43{
44 public void ProcessInput(IInputHandler input)
45 {
46 input.HandleInput();
47 }
48}
49
50// 6. 测试
51class Program
52{
53 static void Main()
54 {
55 Game game = new Game();
56
57 IInputHandler keyboard = new KeyboardInput();
58 game.ProcessInput(keyboard); // 处理键盘输入
59
60 GamepadInput gamepad = new GamepadInput();
61 IInputHandler adaptedGamepad = new GamepadAdapter(gamepad);
62 game.ProcessInput(adaptedGamepad); // 适配后处理手柄输入
63 }
64}
classDiagram
direction LR
class IInputHandler {
+ void HandleInput()
}
class KeyboardInput {
+ void HandleInput()
}
class GamepadInput {
+ void ReadJoystick()
}
class GamepadAdapter {
+ void HandleInput()
}
IInputHandler <|.. KeyboardInput
IInputHandler <|.. GamepadAdapter
GamepadAdapter --> GamepadInput : adapts
代理模式
代理模式在 GoF 中的解释是:“提供一个代理者位置给一个对象,好让代理者可以控制存取这个对象。”
代理模式(Proxy Pattern)是一种结构型设计模式,它提供一个代理对象来控制对另一个对象的访问。代理模式通常用于延迟初始化、访问控制、性能优化和远程代理等场景。
远程代理(Remote Proxy)
在多人在线游戏(MMO)中,客户端可能需要从服务器获取角色数据,但不希望直接暴露 PlayerData。我们可以使用代理模式,让 PlayerProxy 代替客户端直接访问 RealPlayerData,并负责远程通信。
远程代理的好处
- 减少服务器请求,避免不必要的网络开销。
- 客户端不直接依赖服务器,提高安全性。
1using System;
2
3// 1. 目标接口
4public interface IPlayer
5{
6 void GetStats();
7}
8
9// 2. 真实对象(服务器端)
10public class RealPlayerData : IPlayer
11{
12 private string playerName;
13
14 public RealPlayerData(string name)
15 {
16 playerName = name;
17 LoadFromServer(); // 可能涉及网络通信
18 }
19
20 private void LoadFromServer()
21 {
22 Console.WriteLine($"📡 从服务器加载玩家 {playerName} 的数据...");
23 }
24
25 public void GetStats()
26 {
27 Console.WriteLine($"📊 {playerName} 的统计数据:生命值 100,攻击力 50");
28 }
29}
30
31// 3. 代理对象(客户端)
32public class PlayerProxy : IPlayer
33{
34 private RealPlayerData realPlayerData;
35 private string playerName;
36
37 public PlayerProxy(string name)
38 {
39 playerName = name;
40 }
41
42 public void GetStats()
43 {
44 if (realPlayerData == null)
45 {
46 realPlayerData = new RealPlayerData(playerName); // 只有第一次调用时才加载数据
47 }
48 realPlayerData.GetStats();
49 }
50}
51
52// 4. 测试代码
53class Program
54{
55 static void Main()
56 {
57 IPlayer player = new PlayerProxy("Knight");
58
59 Console.WriteLine("🏹 第一次访问数据...");
60 player.GetStats(); // 第一次调用会触发远程加载
61
62 Console.WriteLine("\n🏹 第二次访问数据...");
63 player.GetStats(); // 第二次调用不再请求服务器,直接返回缓存数据
64 }
65}
classDiagram
direction LR
class IPlayer {
+ void GetStats()
}
class RealPlayerData {
+ void GetStats()
}
class PlayerProxy {
+ void GetStats()
}
IPlayer <|.. RealPlayerData
IPlayer <|.. PlayerProxy
PlayerProxy --> RealPlayerData : Fetches data remotely
虚拟代理(Virtual Proxy)— 延迟加载游戏资源
在大型游戏中,3D 模型或贴图可能非常大,我们可以使用虚拟代理来延迟加载资源,提高游戏的初始加载速度。
虚拟代理的好处
- 减少游戏启动时间,延迟加载大资源,提高性能。
- 仅在需要时加载,减少不必要的资源占用。
1// 1. 资源接口
2public interface IGameAsset
3{
4 void Load();
5}
6
7// 2. 真实的高分辨率贴图(可能占用大量内存)
8public class HighResTexture : IGameAsset
9{
10 private string fileName;
11
12 public HighResTexture(string fileName)
13 {
14 this.fileName = fileName;
15 LoadFromDisk();
16 }
17
18 private void LoadFromDisk()
19 {
20 Console.WriteLine($"🖼️ 从磁盘加载高分辨率贴图:{fileName}");
21 }
22
23 public void Load()
24 {
25 Console.WriteLine($"🖼️ 显示贴图:{fileName}");
26 }
27}
28
29// 3. 代理对象,延迟加载贴图
30public class TextureProxy : IGameAsset
31{
32 private HighResTexture highResTexture;
33 private string fileName;
34
35 public TextureProxy(string fileName)
36 {
37 this.fileName = fileName;
38 }
39
40 public void Load()
41 {
42 if (highResTexture == null)
43 {
44 highResTexture = new HighResTexture(fileName); // 只有首次调用时才加载贴图
45 }
46 highResTexture.Load();
47 }
48}
49
50// 4. 测试
51class Program
52{
53 static void Main()
54 {
55 IGameAsset texture = new TextureProxy("Dragon_Skin.png");
56
57 Console.WriteLine("🏹 游戏启动时不加载贴图...");
58 Console.WriteLine("🎮 当玩家进入战斗时,才加载贴图...");
59 texture.Load(); // 第一次调用时加载资源
60
61 Console.WriteLine("\n🎮 进入另一个战斗场景...");
62 texture.Load(); // 之后的调用直接使用已加载的资源
63 }
64}
classDiagram
direction LR
class IGameAsset {
+ void Load()
}
class HighResTexture {
+ void Load()
}
class TextureProxy {
+ void Load()
}
IGameAsset <|.. HighResTexture
IGameAsset <|.. TextureProxy
TextureProxy --> HighResTexture : lazy loads