个人的Unity游戏开发套件与一些示例项目。(开发中,当前Unity版本2022.3.x LTS) 旧v1版本
开发套件基本成型,将会随着个人参与的项目与这里的示例项目的开发而不断更新。
示例项目龟速开发中,主要包含一些具有代表性的、功能较为常见与通用的项目,其中的功能模块可复用到具有类似需求的项目中。
🔗 类幸存者。
亮点:
- Character框架
- 能力系统(属性、技能、Buff)
- 模块化升级
🔗 农场模拟。
🔗回合制策略Rogue-like。
__tables__.xlsx 一键更新工具: luban-helper
🔗一些针对套件的基准测试。
🔗 在Gameplay框架中实现了一套自管理的事件函数(OnCreate、IUpdatable、IFixedUpdatable等),以取代Unity原生的事件函数(Awake、Start、Update、FixedUpdate等)。与原生事件函数相比,自管理的事件函数有序执行,且执行效率略高于原生。
在这个测试中,场景中有20000个猴头在不断地移动和旋转,比较使用自管理实现(IUpdatable)的帧率,和使用原生事件函数(Update)实现的帧率,无论是编辑器中运行,还是打包后运行,自管理都比原生的帧数要高些。
Last: 上一次所有物体Update执行总耗时
Average: 所有物体Update执行总耗时的平均值
UniTask是PamisuKit的唯一第三方依赖项,需要先安装它,可以通过git URL方式安装,或通过Unity Package方式安装。
然后将src/PamisuKit文件夹复制到项目的Packages文件夹下即可。
TODO 之后增加git URL与Unity Package安装方式
🔗包含一些基础的工具实现。
- 简易Addressable资源管理
- 有限状态机
- 对象池
- 事件总线(零GC)
- 工具类(Unity、随机、数学等等)
- 系统内角色划分清晰明确成体系
- 抛弃传统单例模式,所有单例(子系统、服务等)更易于管理
- 自管理的事件函数,事件函数有序执行且效率比原生高
- 集成事件总线,无需手动处理事件的取消订阅
- “区域”让部分元素暂停、倍速功能的实现更简单
首先创建一个类继承AppDirector
,负责处理整个游戏层面的逻辑,整个游戏只会有一个AppDirector
。
App.cs
using PamisuKit.Framework;
public class App : AppDirector<App>
{
}
将其挂载在一个游戏物体上,如果游戏有多场景切换,则需要勾选Dont Destroy On Load选项。
然后创建场景/玩法/游戏模式相关的Director
,负责处理该场景/玩法/游戏模式层面的逻辑,例如游戏有标题、战役两种游戏模式,则可以创建TitleDirector与CombatDirector:
TitleDirector.cs
using PamisuKit.Framework;
public class TitleDirector : Director
{
}
CombatDirector.cs
using PamisuKit.Framework;
public class CombatDirector : Director
{
}
分别在Title场景与Combat场景挂载它们:
接下来编写其他脚本即可,只需要将MonoBehaviour
替换为MonoEntity
,不要使用原生的事件函数如Update
,而是用IUpdatable
这样的接口替代,其他照常:
using PamisuKit.Framework;
using UnityEngine;
public class Player : MonoEntity, IUpdatable
{
[SerializeField]
private float _moveSpeed;
// Player被初始化时执行
protected override void OnCreate()
{
base.OnCreate();
// 初始化玩家角色...
}
// 在OnCreate执行完毕后的下一帧执行
public void OnUpdate(float deltaTime)
{
// Update逻辑...
}
// 使用OnSelfDestroy替代OnDestroy
protected override void OnSelfDestroy()
{
base.OnSelfDestroy();
// 销毁逻辑...
}
}
MonoEntity
需要初始化后才能工作,以下是几种初始化方式:
示例1 创建物体、挂载脚本并初始化
public class CombatDirector : Director
{
private void InitPlayer()
{
// 实例化玩家,将会创建一个名为Player的物体,挂载Player脚本,并执行其初始化
var player = Region.NewMonoEntity<Player>();
}
}
示例2 实例化预制体并初始化
public class MonsterSpawner : MonoEntity
{
[SerializeField]
private GameObject _monsterPrefab;
public void SpawnMonster()
{
// 实例化Monster预制体
var monster = Instantiate(_monsterPrefab, Trans).GetComponent<Monster>();
// 初始化
monster.Setup(Region);
}
}
示例3 勾选Auto Setup自动初始化
导演Director
负责处理某个场景/玩法/游戏模式层面的逻辑,这类逻辑都可以放到其中。Director
同时只能存在一个,场景中所有的MonoEntity
都会注册到Director
中,MonoEntity
中可以使用GetDirector
函数获取到当前Director
:
public class Player : MonoEntity
{
public void Foo()
{
var director = GetDirector<CombatDirector>();
// ...
}
}
GetDirector
的开销非常小,不需要像GetComponent
那样将结果保存为成员变量
Director
有两种模式,Normal
和Global
,可以在Inspector中设置。Global
模式适合整个游戏只有一个Director存在的情况。
场景加载后,场景中的第一个Director将被初始化。当一个模式为Global
的Director被初始化后,它将成为App的子物体,不随场景切换而销毁(前提是App勾选了Dont Destroy On Load),之后再加载其他场景,其中的Director将不会被初始化。
子系统负责处理特定功能模块的逻辑,这个功能模块可以是全局的,也可以是特定游戏模式下的,例如存档系统、成就系统、物品与背包系统、商店系统等等。子系统必须通过Director
或AppDirector
来创建。
编写一个子系统类,继承MonoSystem
:
SaveSystem.cs
using PamisuKit.Framework;
public class SaveSystem : MonoSystem
{
// 被创建时调用
protected override void OnCreate()
{
base.OnCreate();
// 初始化逻辑...
}
}
在Director
中使用CreateMonoSystem
函数来创建它:
using PamisuKit.Framework;
public class CombatDirector : Director
{
protected override void OnCreate()
{
base.OnCreate();
CreateMonoSystem<SaveSystem>();
}
}
这样在运行时将会在CombatDirector物体下创建一个挂载了SaveSystem
脚本的物体。
在上面的基础上,也可以在编辑器中给Director物体下创建子物体并挂载SaveSystem
脚本,方便在Inspector中设置参数,当CreateMonoSystem<SaveSystem>()
执行时,会自动获取SaveSystem子物体并将其初始化。
在MonoEntity
中,使用GetSystem
函数获取指定类型的子系统实例:
public class Player : MonoEntity
{
public void Foo()
{
var saveSystem = GetSystem<SaveSystem>();
// ...
}
}
GetSystem
的开销同样也非常小
MonoSystem
继承自MonoEntity
如果一个子系统需要在整个游戏层面全局存在,可以将它放到AppDirector
中创建。
MonoEntity
是组成游戏世界的实体,Director和子系统之外的职责都可以交给MonoEntity
实现。
MonoEntity
需要被包含在一个Region
即“区域”内,每个MonoEntity
初始化时需要指定其Region
,Director
中会包含一个默认的Region
,初始化时传入的Region
参数将会赋值给成员变量,在初始化其他MonoEntity
时可以使用这个变量。
public class MonsterSpawner : MonoEntity
{
[SerializeField]
private GameObject _monsterPrefab;
public void SpawnMonster()
{
// 实例化Monster预制体
var monster = Instantiate(_monsterPrefab, Trans).GetComponent<Monster>();
// 初始化
monster.Setup(Region);
}
}
当在Inspector中勾选Auto Setup
后,MonoEntity
将会自动初始化:
也可以在代码中覆写(优先使用此值):
public class Player : MonoEntity
{
protected override bool AutoSetupOverride => true;
}
自动初始化将会寻找场景中第一个Director
,调用它的相应函数,使用其中的默认Region
来初始化自身。需要注意自动初始化将会让事件函数的执行回归无序(见自管理的事件函数),考虑到目前大部分使用场景都是手动初始化,该选项默认不勾选(为false),可以在Project Settings -> Player -> Other Settings -> Scripting Define Symbols中添加PAMISUKIT_ENTITY_AUTOSETUP_DEFAULT_ON
将其改为默认true。
如果Director
的初始化为耗时操作,需要等待Director
初始化完毕后才开始MonoEntity
的自动初始化,可以在Director
中自定义这个过程,详见Luban Example中的GameDirector.cs。
通用工具中包含了一个事件总线实现,可以单独使用,下面是独立于Gameplay框架的用法:
// 定义一个事件
public struct GreetingEvent
{
public string Message;
}
public class A : MonoBehaviour
{
public void Foo()
{
// 发送事件
EventBus.Emit(new GreetingEvent { Message = "Hello!"});
}
}
public class B : MonoBehaviour
{
private void Start()
{
// 订阅事件
EventBus.OnRaw<GreetingEvent>(OnGreeting);
}
private void OnDestroy()
{
// 取消订阅
EventBus.Off<GreetingEvent>(OnGreeting);
}
private void OnGreeting(GreetingEvent e)
{
Debug.Log($"Message: {e.Message}");
}
}
事件总线在解耦合时非常有用,但需要记得取消订阅,否则容易出问题。Gameplay框架在MonoEntity
中做了事件总线的集成,事件订阅会被自动管理,下面是基于Gameplay框架的用法:
// 定义一个事件
public struct GreetingEvent
{
public string Message;
}
public class A : MonoEntity
{
public void Foo()
{
// 发送事件
Emit(new GreetingEvent { Message = "Hello!"});
}
}
public class B : MonoEntity
{
protected override void OnCreate()
{
base.OnCreate();
// 订阅事件,无需手动取消订阅
On<GreetingEvent>(OnGreeting);
}
private void OnGreeting(GreetingEvent e)
{
Debug.Log($"Message: {e.Message}");
}
}
当B销毁时,将会自动取消所有事件订阅,这个封装仅会在订阅事件时产生一个订阅对象的内存占用,大小可以忽略不计。
上面通过了解GetDirector
和GetSystem
的使用可以发现,框架使用服务定位器模式替代了传统单例模式,与传统单例模式相比更加灵活和方便管理。
如果游戏中存在无法划分到Director和子系统职责的类,但又需要有单例功能,可以将其注册成服务:
using PamisuKit.Framework;
public class SomeService : MonoEntity
{
protected override void OnCreate()
{
base.OnCreate();
// 创建时注册
RegisterService(this);
}
protected override void OnSelfDestroy()
{
base.OnSelfDestroy();
// 销毁时移除
RemoveService(this);
}
}
在MonoEntity
中,使用GetService
来获取指定服务:
public class Player : MonoEntity
{
public void Bar()
{
var service = GetService<SomeService>();
// ...
}
}
区域Region
是游戏实体MonoEntity
的实际管理者,每个MonoEntity
初始化后都会被包含在一个区域内。区域使用一个钟表Ticker
来驱动所有MonoEntity
的事件函数,通过修改Ticker
的TimeScale
来影响区域内物体的时间流速。
例如使用IUpdatable
时,传入的deltaTime
会受到TimeScale
的影响:
public class Ball : MonoEntity, IUpdatable
{
// ...
public void OnUpdate(float deltaTime)
{
Trans.Translate(_moveSpeed * deltaTime * _moveDirection, Space.World);
}
}
通过以下代码修改Ticker
的TimeScale
,球的运动将会变慢:
public void Foo()
{
Region.Ticker.TimeScale = 0.5f;
}
对于IFixedUpdatable
,deltaTime
固定为Time.fixedDeltaTime
,不会受到TimeScale
的影响,但可以直接使用TimeScale
修改刚体速度:
public class PhysicsBall : MonoEntity, IFixedUpdatable
{
// ...
public void OnFixedUpdate(float deltaTime)
{
_rigidbody.velocity = _moveSpeed * Region.Ticker.TimeScale * _moveDirection;
}
}
一个常见的需求是,游戏内容在暂停和倍速时,UI内容不受影响,可以通过将游戏内容和UI内容放在不同区域来实现,详见示例项目Droid Gear中的GameDirector.cs。
Unity原生的事件函数(Awake、Start、Update、FixedUpdate等)有一个痛点,它们默认是无序执行的,如果需要调整执行顺序,需要在Script Execution Order里手动设置。
无序执行容易带来一些麻烦,产生一些意想不到的Bug,例如场景中有A、B两个MonoBehaviour,二者都需要在Start函数中做初始化,而B需要依赖A,由于AB二者的Start函数执行顺序不确定,B的初始化可能会因此失败,不得不将A的初始化提前至Awake函数。项目中类似的情况变多之后,各脚本的初始化将会变得难以协调。
手动管理是一个更好的选择,并且也符合很多应用场景,例如一个战斗场景中,游戏的全局管理类先初始化,随后调用战斗管理类的初始化,在其中生成玩家与敌人,最后调用玩家与敌人的初始化。但这仍有一点缺陷,Update、FixedUpdate、LateUpdate等函数依然是乱序执行的。
在本Gameplay框架中,着重解决了这个问题,以上面的战斗场景为例,可以编写以下代码:
// 游戏的全局管理类/全局实例
public class App : AppDirector<App>
{
// App的OnCreate最先执行
protected override void OnCreate()
{
// 初始化Director依赖到的全局系统
// ...
// OnCreate中会初始化当前Director
base.OnCreate();
}
}
// 战斗场景中战斗逻辑的管理类
public class CombatDirector : Director
{
// App初始化完毕后,CombatDirector的OnCreate才会执行
protected override void OnCreate()
{
base.OnCreate();
// 初始化战斗相关系统...
// ...
// 实例化玩家
var player = Region.NewMonoEntity<Player>();
}
}
// 玩家角色
public class Player : MonoEntity, IUpdatable
{
// 玩家被生成时执行
protected override void OnCreate()
{
base.OnCreate();
// 初始化玩家角色...
}
// 在OnCreate执行完毕后的下一帧执行
public void OnUpdate(float deltaTime)
{
// Update逻辑...
}
}