【Unity】ECS框架学习笔记(三)——多人联网
环境配置
包体
参考第一篇
项目设置
- RUP-HighFidelity-Renderer => Rendering => Rendering Path 设置为 Forward+
- Project Settings => Player => Resolution and Presentation => Resolution => 勾选 Run In Background
- Project Settings => Player => Other Settings => Configuration => Scripting Backend 设置为 IL2CPP
- Project Settings => Player => Other Settings => Configuration => 勾选 Use incremental GC
- Project Settings => Multiplayer => Create 默认设置就OK
- Preferences => Entities => Baking => Scene View Mode 设置为 Runtime Data
- Create => Unity Physics => Physics Category Names => 自定义名称(可参考文章的射线碰撞检测图片)
夺回生成世界的控制权
在下载NetCode包体之后,启动游戏时Entities会生成ClientWorld和ServerWorld两个世界。
但玩家进入游戏的流程是:先进入主菜单,再进入游戏。在主菜单的时候不用生成ServerWorld,所以我们需要拿到控制权。
将Overide Automatic Netcode Bootstrap
组件挂载到场景中的任意物体上并选择Disable Automatic Bootstrap
因为我们要控制所有场景的世界生成,所以所有场景都需要挂载
挂载之后,启动场景时只会生成Default World
。
然后我们从主菜单进入游戏的时候删除掉Default World
,生成Client World
和Server World
就OK了。
删除默认世界
进入游戏场景时删除Default World
,正好在切换场景的时候删除掉所有内容
1 | // 遍历所有Entity并删除 |


创建服务端和客户端世界
服务端:
1 | private void StartServer() |
获取的
NetworkStreamDriver
组件为NetCode的特殊IComponentData
客户端:
1 | public enum TeamType : byte |
创建客户端的同时向客户端世界中注入ClientTeamSelect
组件,表面自己选择的队伍
再由ClientRequestGameEntrySystem
,捕获进行下一步处理
1 | private void StartClient() |
- 注意:
World.DefaultGameObjectInjectionWorld = clientWorld;
这个很重要,可以直接将对Default World世界的操作同步到Client World,以免每次操作都要使用Client的API。我们已经删除掉默认世界了,所以不会有重复的多余操作- 需要注意的是,这里并没有向服务器发送信息,只是将GameObject的信息传递到Entities的客户端中
客户端向服务器发送进入游戏请求
1 | // 只在客户端运行 |
只在客户端运行:
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
发送数据包:
1
2
3 var requestTeamEntity = ecb.CreateEntity();
ecb.AddComponent(requestTeamEntity, new MobaTeamRequest() { Value = requestedTeam });
ecb.AddComponent(requestTeamEntity, new SendRpcCommandRequest() { TargetConnection = pendingNetworkId });
MobaTeamRequest
为信息内容,虽然是使用AddComponent
添加,但其实是IRpcCommand
,而不是IComponentData
1
2
3
4 public struct MobaTeamRequest : IRpcCommand
{
public TeamType Value;
}
SendRpcCommandRequest
为NetCode特殊组件
- 将Entity传递过去,即上图的
NetworkConnectiuon
- 与服务器的
ReceiveRpcCommandRequest
对应
1
2
3
4
5
6
7 namespace Unity.NetCode
{
public struct SendRpcCommandRequest : IComponentData, IQueryTypeParameter
{
public Entity TargetConnection;
}
}
服务端接收玩家进入请求创建网络连接并创建幽灵系统的Entity
如果这里看不懂的话可以先看下面的RPC和Ghost
1 | [// 只在服务端运行 ] |
这里我们可以查看到数据包长啥样
接受数据包:
1
2
3
4
5
6
7
8
9
10 foreach (var (teamRequest, requestSource, requestEntity)
in SystemAPI.Query<MobaTeamRequest, ReceiveRpcCommandRequest>().WithEntityAccess())
{
ecb.DestroyEntity(requestEntity); // 客户端发送的请求包,但这里需要手动销毁。是同一个实体?
// requestSource.SourceConnection为客户端存放在服务端上的连接器,每个客户端就会存放一个
ecb.AddComponent<NetworkStreamInGame>(requestSource.SourceConnection);
// ...
// 处理数据
// ...
}
创建角色:
1
2
3
4
5
6
7 var newChamp = ecb.Instantiate(championPrefab); // 实例化角色
ecb.SetComponent(newChamp, new GhostOwner() { NetworkId = clientId }); // 设置该实例化角色控制的客户端ID
// 当玩家断线重连时,系统将摧毁旧的服务端Entities,并创建新的服务端Entities
ecb.AppendToBuffer(requestSource.SourceConnection, new LinkedEntityGroup(){ Value = newChamp });
// ...
// 其他设置,如position、队伍、名称等,设置名称后可以直接在`Hierarchy`窗口看到
// ...
GhostOwner
:将实体的控制权交给clientId客户端
1
2
3
4
5
6
7
8
9 namespace Unity.NetCode
{
[ ]
[ ]
public struct GhostOwner : IComponentData
{
[public int NetworkId; ]
}
}将实例化保存在
LinkedEntityGroup
中,是为了统一管理该客户端在服务端的一切事物
- 如果该客户端退出游戏或断线,就摧毁掉该一切与它有关的事物
- 如果重连,就重新创建列表的物体(感觉应该可以控制哪些是要生成的,哪些是不需要的?)
RPC小结
在学习的前先搞清楚一个问题,客户端和服务端连接的方式就只是靠
NetworkConnection
,Ghost系统也只不过是建立在NetworkConnection
上面封装好的工具而已,最终都是需要通过NetworkConnection
在服务端和客户端传递信息。


发送数据模版:
1 | // 下面代码可能无法通过编译,但只是想简单的告诉你发送RPC的方法就是: |
这里的SendRpcCommandRequest
没有指明对象,可以使用new SendRpcCommandRequest() { TargetConnection = pendingNetworkId }
指定客户端ID:
发送者为客户端:
- 客户端只能发送给服务端,所以有没有指定都是一样的。但为了Debug可以设置成自己的Id
发送者为服务端:
- 未指定
TargetConnection
:发送给所有客户端 - 指定
TargetConnection
:发送给指定的客户端
接受数据模版
1 | // 捕获unity转换成ReceiveRpcCommandRequest的实体 |
最重要的一点是在捕获到数据包后一定要删除掉该实体。要不然这个数据一直存在,服务端就以为是客户端在不停的发送数据。
流程梳理
传递的枚举TeamType数据一共有三个组件,別搞混了
- 保存在Client World和Server World的
ClientTeamSelect
、MobaTeam
两个都是IcompoentData
,是普通组件,不是用来RPC传递数据的为什么不直接使用
ClientTeamSelect
(思维整理,可不看):ClientTeamSelect
不会挂载到任何实例化对象上(也就是说不会挂载在Ghost上)。他会就这样永远孤独的漂浮在Client World中,如果想删除它也可以。MobaTeam
中数据使用[GhostField]
修饰了,所以客户端会给对应的Ghost上添加MobaTeam
组件,并且同步该数据。- 所以我们没有使用
ClientTeamSelect
来当做客户端的队伍,要不然我们还需要额外的精力去同步。
MobaTeamRequestRPC
是IRpcCommand
通信用的,可以看做的临时中转站
发送数据步骤:
- Client World创建一个entity,在上面添加需要传递的数据组件和
SendRpcCommandRequest
组件 - NetCode捕获到到该entity后并删掉,同时在Server World还原这个entity(只将
SendRpcCommandRequest
改为了ReceiveRpcCommandRequest
,其他数据完全一样)。这一步骤是NetCode完全自动化完成的 - 程序员在Server World使用
ReceiveRpcCommandRequest
手动捕获系统还原的entity,成功获取到数据
扩展测试实验(可不看):
关于这两个数据包到底是不是同一个Entity的问题:
直接使用代码获取到这两个Entity的Index
、Version
、HashCode
就知道了客户端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 public void OnUpdate(ref SystemState state)
{
Entity requestTeamEntity = Entity.Null;
// 输出刚创建时的对象信息,以防狸猫换太子
Debug.Log($"Send 1 : {requestTeamEntity == Entity.Null} Index: {requestTeamEntity.Index} Version: {requestTeamEntity.Version} HashCode: {requestTeamEntity.GetHashCode()}");
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (Entity pendingNetworkId in pendingNetworkIds)
{
requestTeamEntity = ecb.CreateEntity();
ecb.AddComponent(requestTeamEntity, new MobaTeamRequest() { Value = requestedTeam });
ecb.AddComponent(requestTeamEntity, new SendRpcCommandRequest() { TargetConnection = pendingNetworkId });
}
ecb.Playback(state.EntityManager);
Debug.Log($"Send 1 : {requestTeamEntity == Entity.Null} Index: {requestTeamEntity.Index} Version: {requestTeamEntity.Version} HashCode: {requestTeamEntity.GetHashCode()}");
}(不重要,可以跳过不看)代码为什么写成这样,要输出次:为了确认Entity是在缓存器执行完之后的信息,所以将其放在了最后面。然后又因为
requestTeamEntity
是定义在foreach中的,所以得现在最上面创建一个空的Entity,这样才能在.Playback()
后面使用该Entity,要不然编译都不通过,并且从输出上来看,我们确实是输出了正确的Entity信息拓展:经过实际上的测试,发现
requestTeamEntity = ecb.CreateEntity();
在这一步创建空物体的时候就已经确定了该物体的Index
、Version
、HashCode
了,所以在任意地方输出都可以服务端:直接在捕获后输出信息
1
2
3
4 foreach (var (teamRequest, requestSource, requestEntity)
in SystemAPI.Query<MobaTeamRequestRPC, ReceiveRpcCommandRequest>().WithEntityAccess())
{
Debug.Log($"Receive 0 : Index: {requestEntity.Index} Version: {requestEntity.Version} HashCode: {requestEntity.GetHashCode()}");
为了实验的严谨性,该玩家是同时创建了客户端和服务端,这两个世界都是在他这一个程序上工作的。
但是从输出结果可以看出来,两个Entity确实不是同一个。
并且如果仔细想想,确实两个世界都不一样,怎么也不太可能是同一个Entitiy
Ghost设置
在初始化角色前,先介绍一下客户端和服务端是怎么同步的——幽灵组件GhostAuthoringComponent
需要实现服务器与客户端同步的物体,就需要挂载幽灵组件,需要注意的点:
- 幽灵系统的作用:将两个世界里创建的
ICompnentData
复制一次到对面世界。仅仅只是复制,复制完后他们就没有任何联系了,甚至可以手动删除其中一个世界的组件。除非使用幽灵属性标记。 - 幽灵数据
[GhostField]
作用:将服务器的数据映射显示到客户端上,客户端并不能直接修改被标记为[GhostField]
的数据。就算修改了,也会被系统快照强制覆盖掉 GhostAuthoringComponent
脚本必须挂载在预制体上,幽灵Entity通常是在运行的时候生成,而不是场景一开始就存在。当然,既然是Entity那就只能存在于子场景Eneities World中
可能会有一个误区:
[GhostField]
属性只是告诉系统这个数据的控制权应该完全交给服务器。
- 如果你给服务器的Entity添加
IComponentData
,就算这个组件中没有[GhostField]
,因为幽灵系统的存在,系统也会给客户端的Ghost添加该组件- 更不要觉得如果一个
IComponentData
中有的数据有[GhostField]
有的数据没有,系统就不会复制未被标记的数据。幽灵系统复制的IComponentData
复制的是整个组件,而不是单独是数据。
脚本可以强行挂载在场景中一开始就存在的实体。这么做的话所有的配置都只能在预制体Asset本体上设置,场景中的每个实例化无法单独更改。
配置:
Importance
:数据优先级,数值越大越优先发送SupportedGhostModes
:幽灵支持的模式,选择适合的模式能提供更好的优化,运行时无法更改此值DefaultGhostMode
:幽灵同步模式Interpolated
:轻量级,不在客户端上执行模拟。但他们的值是从最近几个快照的插值,此模式时间轴要慢于服务器。与玩家关联不大的,如防御塔、小兵、水晶基地等。Predicted
:完全由客户端预测。这种预测既昂贵又不权威,但它确实允许预测的幽灵更准确地与物理交互,并且它确实将他们的时间轴与当前客户端对齐。与玩家交互强相关的才需要设置成这个,如英雄,飞行道具等。OwnerPredicted
:由Ghost Owner
(即服务端)预测,并且还会差值其他的客户端。是上面两个模式的折中方案。
OptimizationMode
:优化模式,针对是否会频繁的传递数据的优化Dynamic
:希望Ghost经常更改(即每帧)时使用。优化快照的大小,不执行更改检查,执行delta-compression
(增量压缩)Static
:优化不经常改变的Ghosts。节省带宽,但是需要额外的CPU周期来执行检查是否有数据更改,如果更改了就发送数据给服务器。如果设置不对给经常改变的Ghost了,将增加带宽和cpu成本
HasOwner
:控制权是否是在玩家手上的,必须指定NetwordId
的值SupportAutoCommandTarget
:勾选后将标记为[GhostField]
的数据自动生成缓冲数据发送给服务器
预测组
凡是涉及到数值,并且与玩家、战斗强相关的system都需要预测,使数据更平滑,而不是一个梯度一个梯度的更改数值。
关于”平滑”指的是什么可以参考第二篇文章的Tick的讲解
使用[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]属性,表示该system运行在模拟组内,该组客户端和服务端都会运行。
注意:
放置在该组后,system处理的entity如果是设置了Ghost的阈值体,那么
DefaultGhostMode
必须设置成Predicted
。不然会出现很奇怪的BUG:虽然在systems、inspector窗口上该entitiy没有任何问题,但是客户端的所有system都只能在游戏开始的短暂一瞬间(13个update左右)捕获到该entity
1
2
3
4
5
6
7
8
9
10
11
12 [ ]
public partial class RespawnChampSystem : SystemBase
{
protected override void OnUpdate()
{
foreach (var respawnBuffer in SystemAPI
.Query<DynamicBuffer<RespawnBufferElement>>().WithAll<RespawnTickCount, Simulate>())
{
Debug.Log($"{(isServer? "Server" : "Client")}, 成功捕获到物体");
}
}
}
如果将DefaultGhostMode
设置成Interpolated
就会像下方这样出现很奇怪的BUG
组件
再重复一遍,客户端和服务端连接的方式就只是靠
NetworkConnection
,Ghost系统也只不过是建立在NetworkConnection
上面封装好的工具而已,最终都是需要通过NetworkConnection
在服务端和客户端传递信息。
GhostOwner
和GhostOwnerIsLocal
这两个组件在main entity和ghost entity上都绑定了
GhostOwner
只有一个幽灵数据,由服务端控制,表示该entity是属于那个客户端的
1 | [ ] |
GhostOwnerIsLocal
表面上看起来虽然是一个简单的可开关的标签,但其实是一个Ghost标签,客户端无法更改这个标签。其实该组件就是为了让客户端能快速定位到自己控制的Ghost entity而存在的。如果还嫌不够快的话,可以在自己创建一个单例标签。
1 | public struct GhostOwnerIsLocal : IComponentData, IEnableableComponent { } |
两个组件通力合作,在服务端上设置GhostOwner.NetworkId = 2
之后,那么
- 对于该客户端而言:ghost entity的
GhostOwner.NetworkId = 2
的GhostOwnerIsLocal
才是true,其他都是false - 对于服务端而言:所有的
GhostOwnerIsLocal
都是true
属性
字段属性
[GhostField]
:完全由系统控制,系统会同步数值
Struct组件属性
[GhostComponent()]
:常用于IcommandData
、IBufferElementData
等。可设置参数如下:PrefabType
:设置该组件是否需要烘焙到客户端上,例如:GhostPrefabType.AllPredicted
:Predicted
模式的实体,客户端和服务端都会烘焙;Interpolated
和Owner Predicted
不会烘焙客户端GhostPrefabType.All
(默认选项):所有类型,客户端、服务端都会烘焙
OwnerSendType
:控制数据需要发送给那些客户端。对无服务没有影响,仅仅只是控制是否会 发送给客户端而已,比如计算的一些中间值就可以不用同步给客户端SendToNonOwner
:表示该数据只会发送给非拥有者的其他客户端None
:表示所有客户端都不会发送。适合一些中转数据,这类数据对客户端没影响,客户端也不需要这种数据,以减少带宽
初始化角色
前言
在给物体添加组件时先思考一下,这个客户端和服务端是否都需要用到这个组件
可以创建三个初始化system:客户端、服务端和共同的。例如,输入系统不需要在服务端运行,那么就只用将其在客户端system上初始化就OK了
如果使用了Ghost,那么在Authoring中烘焙的所有组件都同时会存在在客户端和服务端上
1 | // [UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)] // 共同初始化 |
物理
添加下面两个组件


Rigibody
:Use Gravity
:为了避免服务端和客户端不同步,我们不能使用物理系统。Is Kinematic
:启用后物理系统将不能施加力来移动或旋转物体,而只能改变Transform来移动旋转物体
1
physicsMass.ValueRW.InverseInertia = float3.zero; // 设置惯性为无限
Physcis Shape
:自定义物体包围盒,通常是在预制体的时候设置好。该组件需要在Package Manage
的Unity Physics
的Samples
中下载Custom Physics Authoring
颜色
在烘焙的时候添加URPMaterialPropertyBaseColor
组件或者直接在Inpector面板添加
1 | var teamColor = mobaTeam.Value switch |
输入
- 客户端唯一拥有的权限就是输入
- 但判断你输入的内容是否有效合法,决定权还是在服务器
- 输入数据是客户端和服务器之间的非常频繁传递的数据
- 有一个专门用来传递的组件
IInputComponentData
,比ICommandData
要好用
ICommandData
如果需要频繁的从客户端向服务器发送数据应该使用
ICommandData
而不是RPC
,ICommandData
有优化必须是从客户端发送到服务器。客户端只需要执行赋值操作,unity会自动将数据同步到服务器。用来控制实体的命令,或是保存状态,如技能CD、伤害等凡是与时间相关的
类似于一个
Dynamic Buffer
动态缓冲器,他将保存最后64NetWorkTick
的数据默认情况下不会从服务器复制到所有客户端,需要使用
GhostComponent
属性设置(例如:技能恢复时间,其他玩家不需要知道你的技能什么时候恢复)。因为ICommandData
的工作方式,不建议设置为SendToOwnerType.SendToOwner
,将被视为错误并并忽略
1 | public struct DamageThisTick : ICommandData{ |
通过测试发现:
- 在计算并发送数据前可以使用
if (state.WorldUnmanaged.IsServer()) continue;
,从而提高服务器性能。当然就算没有使用这个,功能也能照常正常使用.AddCommandData()
添加数据后,客户端会得到一组数据,但服务端会得到四十多个数据(但系统其实也只是判断了一次,因为他们都有相同的Tick),但并不影响结果。unity这么做的原因应该是担心服务器没有接受到数据
IInputComponentData
“继承”IComponentData
,且不需要设置Tick,系统自动完成
InputEvent
是存储在IInputComponentData
中的输入事件,一般用来传递按钮事件。服务器一般为 60帧,如果客户端为120帧,服务器很有可能检测不到玩家的输入,InputEvent
针对这个问题进行的优化。
使用方法
从玩家输入到游戏中显示效果,需要两个系统来实现效果,拿移动来举例
MoveInputSystem
:在客户端上运行,获取玩家输入- 添加到
GhostInputSystemGroup
中,该Group只存在于Client World中 - 需要监听输入,并将输入转换成
IInputComponentData
组件上的数据,存放在该客户端GhostOwnerIsLocal
的Ghost Entity上 - unity会自动同步该数据,即使你没有使用
[GhostField]
修饰数据
- 添加到
MoveSystem
:同时在客户端和服务端上运行,执行玩家的命令- 添加到
PredictedSimulationSystemGroup
中,客户端和服务端都会运行,并且会模拟预测数据 - unity将数据从客户端同步到服务端之后,就可以捕获到数据并执行命令了
- 添加到
传递普通数据:
设置需要传递的数据
1 | // [GhostComponent(PrefabType = GhostPrefabType.AllPredicted)] // 可以优化需要传递的对象 |
将system添加到GhostInputSystemGroup
中并。然后设置数值,系统会自动帮我们实现同步
1 | [// 添加到输入组,该组只存在于Client中 ] |
传递InputEvent
数据
定义IInputComponentData
和InputEvent
,并将其烘焙到Entitiy上
1 | // [GhostComponent(PrefabType = GhostPrefabType.AllPredicted)] // 可以优化需要传递的对象 |
1 | [// 添加到输入组,该组只存在于Client中 ] |
InputEvent
类似一个计数器,客户端使用Set()
之后。
服务端检测到其大于0,那么就会判定为输入了,同时将其复原成0。
这样就避免了服务器帧率没有客户端高,而引发的监听不到输入的问题了。InputEvent
常用在类似按钮的触发事件,移动摇杆这类常用的移动输入直接使用数值就OK了
这里说个题外话:关于
var newAbilityInput = new AbilityInput();
- 因为结构体是值类型,所以在
OnUpdate
执行完后会直接释放掉,不涉及垃圾回收(也就是GC)- 由于栈的分配效率非常高效,即使
OnUpdate
每帧都在调用,数据量不大的话也不会对性能有影响
射线碰撞检测
1 | private void OnSelectMovePosition() |
1 | // 球形碰撞 |
销毁
步骤:
在预制体上烘焙
DestoryOnTimer
,预设物体多久后销毁客户端和服务端都会运行的
InitializeDestoryOnTimerSystem
捕获拥有DestoryOnTimer
组件的实体,并计算该实体在多少Tick的时候销毁,将计算的事件存储在DestroyAtTick
的Ghost数据上- 服务器计算时间不能以
deltaTime
(不够稳定)或frame
(客户端和服务端会不一致) - 而是使用
ServerTick
,说简单点其实频率,默认为60Tick/秒,与普通的帧还不太一样
- 服务器计算时间不能以
客户端和服务端都会运行的
DestroyOnTimerSystem
捕获拥有DestroyAtTick
组件的实体,并判断该实体是否已达到销毁时间,达到销毁时间后挂载DestroyEntityTag
标签- 该system只是添加标签,并不是直接销毁
- 为什么不直接销毁?因为凡是涉及到客户端表现的都需要做预测,也就是得放置在
PredictedSimulationSystemGroup
中
放置在
PredictedSimulationSystemGroup
(预测组——服务端和客户端都会预测)中的DestroyEntitySystem
捕获拥有DestroyEntityTag
标签的实体进行处理。暂时没有很好的办法使客户端和服务端同时进行销毁,当前的做法如下:- 服务端:直接摧毁
- 客户端:将物体移动到不可见的位置,并等待服务器同步
从下图可看出,有一半的次数客户端要晚于服务端摧毁,并且实际运用起来可能不止一半
共涉及两个组件、一个标签、三个系统
1 | public struct DestroyAtTick : IComponentData |
1 | public struct DestroyEntityTag : IComponentData { } // 达到了被销毁时间的entitiy |
步骤一:预设烘焙
1 | public struct DestroyOnTimer : IComponentData |
步骤二:计算销毁时间
1 | // 默认客户端和服务端都会执行,计算多少Tick销毁,并添加DestroyAtTick |
步骤三:判断是否达到销毁Tick
1 | // 默认客户端和服务端都会执行,判断物体是否达到销毁Tick,并添加DestroyEntityTag |
步骤四:处理服务端和客户端
1 | // 预测,客户端和服务端都会预测 |
伤害
步骤:
- 预设烘焙:
- 可造成伤害实体(飞行物、刀剑等):
DamageOnTrigger
:设置伤害量AlreadyDamagedEntity
:存储造成伤害的entity,防止重复造成伤害
- 可受伤的实体(英雄、防御塔、小兵等):
DamageBufferElement
:存储伤害值DamageThisTick
:记录在服务器的多少帧受到多少伤害,并将伤害应用到实体上
- 可造成伤害实体(飞行物、刀剑等):
- 监听碰撞:使用
ITriggerEventsJob
监听碰撞,并执行以下处理- 将伤害值添加到受击者的
DamageBufferElement
伤害缓存池中 - 将受击者添加到攻击者的
AlreadyDamagedEntity
中,防止重复计算伤害
- 将伤害值添加到受击者的
- 计算伤害:捕获
DamageBufferElement
并将其数值和当前Tick存储在DamageThisTick
中 - 实施伤害:捕获
DamageThisTick
并判断是否是当前帧,只有是当前帧的伤害才实施
共涉及4个组件和三个系统,所有系统都是在PredictedSimulationSystemGroup
预测组上运行的
第一步:预设烘焙
1 | public struct DamageOnTrigger : IComponentData |
1 | public struct AlreadyDamagedEntity : IBufferElementData |
1 | [ ] |
1 | // 发送给除entity控制者以外的所有其他客户端Ghost entity |
第二步:监听碰撞
注意:使用的是ITriggerEventsJob
而不是普通的IJobEntity
1 | [// 涉及到物体碰撞(即客户端表现),所以放在PhysicsSystemGroup之中 ] |
第三步:计算伤害
1 | [ ] |
第四步:实施伤害
1 | [ ] |
技能CD
在释放技能前判断是否达到CD
1 | public struct AbilityCooldownTicks : IComponentData |
1 | [ ] |
为了避免服务器跳帧,还需要将前几Tick的数据来比较
下面的代码可能会让人很困惑,为什么要多此两举的,既要将释放技能的时机向后移动1Tick,又要在释放技能的时候把前几Tick的数据来做比较。但这种方法是经过社区测试,而得出的一个相对较好的判断技能CD的方式。
1 | var isOnCooldown = true; |
UI
ICleanupComponentData
与普通的组件类似,但有一些特殊规则
- 无法烘焙
- 当你Destroy包含cleanup的entity时,并不会真正的删除他,而是会移除掉他身上所有非cleanup的组件。除非你将cleanup也移除,才能真正的destroy该netity
通常用在不同系统的销毁处理上,比如角色和血条他们两并不是父子关系(一个在Entity,一个在GameObject),而是引用关系
未使用cleanup:在销毁掉角色时并不会销毁掉血条,反而移除掉了血条的引用,使我们无法找到血条的引用
使用cleanup:在删除掉角色时,血条的组件还是会被保存下来,血条system就可以根据这个引用找到并删除掉血条了
除非我们在删除entity前将血条也删除了,但是这样代码不易维护,并且该entity并不一定有血条
血条系统
组件
1 | public struct HealthBarOffset : IComponentData |
烘焙
每个可受伤的角色都烘焙一个位置偏差值
1 | public class HealthBarUIReference : ICleanupComponentData // 无法烘焙 |
直接在场景创建一个单例,保存血条预制体,方便我们创建血条
1 | public class UIPrefabs : IComponentData |
HealthBarUIReference
:场景中每个可受伤的entity都有用一个该组件UIPrefabs
:该组件整个场景中只有一个,保存UI的预制体方便创建UI
系统
1 | [// 在玩家移动后的进行位置设置 ] |
技能冷却
使用单例控制图标的进度,这里就展示代码了
1 | [ ] |
退出服务器
在客户端或者GameObject世界执行如下操作
- 获取到
NetworkStreamConnection
实体,并添加NetworkStreamRequestDisconnect
组件 - 销毁世界,并加载其他场景
1 | var networkConnection = SystemAPI.GetSingletonEntity<NetworkStreamConnection>(); |
虚拟客户端
创建
[WorldSystemFilter(WorldSystemFilterFlags.ThinClientSimulation)]
:只在虚拟客户端运行。如创建一个英雄、设置CommandTarget
连接[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
:虚拟客户端和普通客户端都要运行。如英雄初始化(设置英雄标签、移动目标位置为自身脚下)
虚拟客户端创建假人,并表名自己选择的队伍,只需要最低限度的添加需要的组件。这一步类似客户端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[ ]
public partial struct ThinClientEntrySystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
state.Enabled = false; // 只需要创建一次
var thinClientDummy = state.EntityManager.CreateEntity(); // 创建一个假人entity
// 添加移动组件 "ChampMoveSystem" 需要使用
state.EntityManager.AddComponent<ChampMoveTargetPosition>(thinClientDummy);
// 在移动组件中添加一个数据,告诉假人要移动到哪里
state.EntityManager.AddBuffer<InputBufferData<ChampMoveTargetPosition>>(thinClientDummy);
// 随机选择一个队伍,再由"ClientRequestGameEntrySystem"捕获,向服务器发送进入游戏请求
var thinClientRequestEntity = state.EntityManager.CreateEntity();
state.EntityManager.AddComponentData(thinClientRequestEntity, new ClientTeamSelect
{
Value = TeamType.AutoAssign
});
}
}客户端向服务器发送进入游戏请求,这一步普通客户端和虚拟客户端都需要使用,详情见客户端向服务器发送进入游戏请求
服务端在接收到请求并创建服务端实体,这一步是服务器完成的
如此一来,就能在场景中生成虚拟客户端了


控制
在测试的时候发现,我们修改了虚拟客户端的ChampMoveTargetPosition
,但是并不起作用(因为修改的内容并没有传递给服务器)。如何能一直控制他们移动呢,unity为我们在NetworkConnection上准备了CommandTarget
组件。
在创建第一步创建假人的最后设置
CommandTarget
组件,并添加移动用的组件1
2
3
4
5
6
7
8
9public struct ThinClientInputProperties : IComponentData
{
public Random Random; // 移动随机数
public float Timer; // 移动计时器
public float MinTimer; // 停留的最小时间
public float MaxTimer; // 停留的最大时间
public float3 MinPosition; // 移动的最小范围
public float3 MaxPosition; // 移动的最大范围
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17var connectionEntity = SystemAPI.GetSingletonEntity<NetworkId>(); // 获取连接id
// 设置命令目标
SystemAPI.SetComponent(connectionEntity, new CommandTarget { targetEntity = thinClientDummy });
var connectionId = SystemAPI.GetSingleton<NetworkId>().Value;
// 添加GhostOwner
state.EntityManager.AddComponentData(thinClientDummy, new GhostOwner { NetworkId = connectionId });
state.EntityManager.AddComponentData(thinClientDummy, new ThinClientInputProperties
{
Random = Random.CreateFromIndex((uint)connectionId),
Timer = 0f,
MinTimer = 1f,
MaxTimer = 10f,
MinPosition = new float3(-50f, 0f, -50f),
MaxPosition = new float3(50f, 0f, 50f)
});在第三步的时候将服务器这边虚拟客户端控制的英雄实体赋给他自己的NetworkConnection的CommandTarget上
1
2
3// ... 创建服务端英雄实体
ecb.SetComponent(requestSource.SourceConnection, new CommandTarget { targetEntity = newChamp });
// ...验证是否满足开始游戏条件

