环境配置 包体 参考第一篇
项目设置
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 2 3 4 5 6 7 8 9 10 11 foreach (var world in World.All){ if (world.Flags == WorldFlags.Game) { world.Dispose(); break ; } } SceneManager.LoadScene(1 , LoadSceneMode.Single);
创建服务端和客户端世界 服务端: 1 2 3 4 5 6 7 8 9 10 11 private void StartServer (){ var serverWorld = ClientServerBootstrap.CreateServerWorld("Coffee Server World" ); var serverEndpoint = NetworkEndpoint.AnyIpv4.WithPort(7979 ); { using var networkDriverQuery = serverWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>()); networkDriverQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Listen(serverEndpoint); } }
获取的NetworkStreamDriver
组件为NetCode的特殊IComponentData
客户端: 1 2 3 4 5 6 7 8 9 10 11 public enum TeamType : byte { None = 0 , Blue = 1 , Red = 2 , AutoAssign = byte .MaxValue } public struct ClientTeamSelect : IComponentData{ public TeamType Value; }
创建客户端的同时向客户端世界中注入ClientTeamSelect
组件,表面自己选择的队伍
再由ClientRequestGameEntrySystem
,捕获进行下一步处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 private void StartClient (){ var clientWorld = ClientServerBootstrap.CreateClientWorld("Coffee Client World" ); var connectionEndpoint = NetworkEndpoint.Parse("127.0.0.1" , 7979 ); { using var networkDriverQuery = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>()); networkDriverQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Connect(clientWorld.EntityManager, connectionEndpoint); } World.DefaultGameObjectInjectionWorld = clientWorld; var team = _teamDropdown.value switch { 0 => TeamType.AutoAssign, 1 => TeamType.Blue, 2 => TeamType.Red, _ => TeamType.None }; var teamRequestEntity = clientWorld.EntityManager.CreateEntity(); clientWorld.EntityManager.AddComponentData(teamRequestEntity, new ClientTeamSelect() { Value = team }); }
注意 :World.DefaultGameObjectInjectionWorld = clientWorld;
这个很重要,可以直接将对Default World世界的操作同步到Client World,以免每次操作都要使用Client的API。我们已经删除掉默认世界了,所以不会有重复的多余操作
需要注意的是,这里并没有向服务器发送信息,只是将GameObject的信息传递到Entities的客户端中
客户端向服务器发送进入游戏请求 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation) ] public partial struct ClientRequestGameEntrySystem : ISystem{ private EntityQuery _pendingNetworkIdQuery; public void OnCreate (ref SystemState state ) { var builder = new EntityQueryBuilder(Allocator.Temp).WithAll<NetworkId>().WithNone<NetworkStreamInGame>(); _pendingNetworkIdQuery = state.GetEntityQuery(builder); state.RequireForUpdate(_pendingNetworkIdQuery); state.RequireForUpdate<ClientTeamSelect>(); } public void OnUpdate (ref SystemState state ) { var requestedTeam = SystemAPI.GetSingleton<ClientTeamSelect>().Value; var ecb = new EntityCommandBuffer(Allocator.Temp); var pendingNetworkIds = _pendingNetworkIdQuery.ToEntityArray(Allocator.Temp); foreach (Entity pendingNetworkId in pendingNetworkIds) { ecb.AddComponent<NetworkStreamInGame>(pendingNetworkId); var requestTeamEntity = ecb.CreateEntity(); ecb.AddComponent(requestTeamEntity, new MobaTeamRequest() { Value = requestedTeam }); ecb.AddComponent(requestTeamEntity, new SendRpcCommandRequest() { TargetConnection = pendingNetworkId }); } ecb.Playback(state.EntityManager); } }
只在客户端运行:
[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 });
服务端接收玩家进入请求创建网络连接并创建幽灵系统的Entity 如果这里看不懂的话可以先看下面的RPC和Ghost
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation) ] public partial struct ServerProcessGameEntryRequestSystem : ISystem{ public void OnCreate (ref SystemState state ) { state.RequireForUpdate<MobaPrefabs>(); var builder = new EntityQueryBuilder(Allocator.Temp).WithAll<MobaTeamRequest, ReceiveRpcCommandRequest>(); state.RequireForUpdate(state.GetEntityQuery(builder)); } public void OnDestroy (ref SystemState state ) { } public void OnUpdate (ref SystemState state ) { var ecb = new EntityCommandBuffer(Allocator.Temp); var championPrefab = SystemAPI.GetSingleton<MobaPrefabs>().Champion; foreach (var (teamRequest, requestSource, requestEntity) in SystemAPI.Query<MobaTeamRequest, ReceiveRpcCommandRequest>().WithEntityAccess()) { ecb.DestroyEntity(requestEntity); ecb.AddComponent<NetworkStreamInGame>(requestSource.SourceConnection); var requestedTeamType = teamRequest.Value; if (requestedTeamType == TeamType.AutoAssign) { requestedTeamType = TeamType.Blue; } float3 spawnPosition = new float3(0 , 1 , 0 ); switch (requestedTeamType) { case TeamType.Blue: spawnPosition = new float3(-50f , 1f , -50f ); break ; case TeamType.Red: spawnPosition = new float3(50f , 1f , -50f ); break ; default : continue ; } var clientId = SystemAPI.GetComponent<NetworkId>(requestSource.SourceConnection).Value; var newChamp = ecb.Instantiate(championPrefab); ecb.SetName(newChamp, "Champion" ); var newTransform = LocalTransform.FromPosition(spawnPosition); ecb.SetComponent(newChamp, newTransform); ecb.SetComponent(newChamp, new GhostOwner() { NetworkId = clientId }); ecb.SetComponent(newChamp, new MobaTeam() { Value = requestedTeamType }); ecb.AppendToBuffer(requestSource.SourceConnection, new LinkedEntityGroup(){ Value = newChamp }); } ecb.Playback(state.EntityManager); } }
这里我们可以查看到数据包长啥样
接受数据包:
1 2 3 4 5 6 7 8 9 10 foreach (var (teamRequest, requestSource, requestEntity) in SystemAPI.Query<MobaTeamRequest, ReceiveRpcCommandRequest>().WithEntityAccess()) { ecb.DestroyEntity(requestEntity); ecb.AddComponent<NetworkStreamInGame>(requestSource.SourceConnection); }
创建角色:
1 2 3 4 5 6 7 var newChamp = ecb.Instantiate(championPrefab); ecb.SetComponent(newChamp, new GhostOwner() { NetworkId = clientId }); ecb.AppendToBuffer(requestSource.SourceConnection, new LinkedEntityGroup(){ Value = newChamp });
RPC小结
在学习的前先搞清楚一个问题,客户端和服务端连接的方式就只是靠NetworkConnection
,Ghost系统也只不过是建立在NetworkConnection
上面封装好的工具而已,最终都是需要通过NetworkConnection
在服务端和客户端传递信息。
发送数据模版: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public struct LoadLevelRPC : IRpcCommand{ public int LevelIndex; } private void LoadNewLevel (int levelIndex ){ var rpcEntity = EntityManager.CreateEntity(typeof (LoadLevelRPC), typeof (SendRpcCommandRequest)); EntityManager.SetComponentData(rpcEntity, new LoadLevelRpc() { LevelIndex = levelIndex }); }
这里的SendRpcCommandRequest
没有指明对象,可以使用new SendRpcCommandRequest() { TargetConnection = pendingNetworkId }
指定客户端ID:
发送者为客户端:
客户端只能发送给服务端,所以有没有指定都是一样的。但为了Debug可以设置成自己的Id
发送者为服务端:
未指定TargetConnection
:发送给所有客户端
指定TargetConnection
:发送给指定的客户端
接受数据模版 1 2 3 4 5 6 7 foreach (var (levelToLoad, rpcEntity) in SystemAPI.Query<LoadLevelRPC>().WithAll<ReceiveRpcCommandRequest>().WithEntityAccess()) { ecb.DestroyEntity(rpcEntity); LoadLevel(levelToLoad.LevelIndex); }
最重要的一点是在捕获到数据包后一定要删除 掉该实体。要不然这个数据一直存在,服务端就以为是客户端在不停的发送数据。
流程梳理 传递的枚举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 [UpdateInGroup(typeof(PredictedSimulationSystemGroup)) ] 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 2 3 4 5 6 [DontSupportPrefabOverrides ] [GhostComponent(SendDataForChildEntity = true) ] public struct GhostOwner : IComponentData{ [GhostField ] public int NetworkId; }
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public partial struct InitializeCharacterSystem : ISystem{ public void OnUpdate (ref SystemState state ) { var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (physicsMass, mobaTeam, newCharacterEntity) in SystemAPI.Query<RefRW<PhysicsMass>, MobaTeam>().WithAll<NewChampTag>().WithEntityAccess()) { ecb.RemoveComponent<NewChampTag>(newCharacterEntity); } ecb.Playback(state.EntityManager); } }
物理 添加下面两个组件
颜色 在烘焙的时候添加URPMaterialPropertyBaseColor
组件或者直接在Inpector面板添加
1 2 3 4 5 6 7 8 var teamColor = mobaTeam.Value switch { TeamType.Blue => new float4(0 , 0 , 1 , 1 ), TeamType.Red => new float4(1 , 0 , 0 , 1 ), _ => new float4(1 ) }; ecb.SetComponent(newCharacterEntity, new URPMaterialPropertyBaseColor{ Value = teamColor });
输入
客户端唯一拥有的权限就是输入
但判断你输入的内容是否有效合法,决定权还是在服务器
输入数据是客户端和服务器之间的非常频繁传递的数据
有一个专门用来传递的组件IInputComponentData
,比ICommandData
要好用
如果需要频繁的从客户端向服务器发送数据应该使用ICommandData
而不是RPC
,ICommandData
有优化
必须是从客户端发送到服务器 。客户端只需要执行赋值操作,unity会自动将数据同步到服务器。用来控制实体的命令,或是保存状态,如技能CD、伤害等凡是与时间相关的
类似于一个Dynamic Buffer
动态缓冲器,他将保存最后64NetWorkTick
的数据
默认情况下不会从服务器复制到所有客户端,需要使用GhostComponent
属性设置(例如:技能恢复时间,其他玩家不需要知道你的技能什么时候恢复) 。因为ICommandData
的工作方式,不建议设置为SendToOwnerType.SendToOwner
,将被视为错误并并忽略
1 2 3 4 public struct DamageThisTick : ICommandData{ public NetworkTick Tick {get ; set ;} public int Value }
通过测试发现:
在计算并发送数据前可以使用if (state.WorldUnmanaged.IsServer()) continue;
,从而提高服务器性能。当然就算没有使用这个,功能也能照常正常使用
.AddCommandData()
添加数据后,客户端会得到一组数据,但服务端会得到四十多个数据(但系统其实也只是判断了一次,因为他们都有相同的Tick),但并不影响结果。unity这么做的原因应该是担心服务器没有接受到数据
“继承”IComponentData
,且不需要设置Tick,系统自动完成
InputEvent
是存储在IInputComponentData
中的输入事件,一般用来传递按钮事件。服务器一般为 60帧,如果客户端为120帧,服务器很有可能检测不到玩家的输入,InputEvent
针对这个问题进行的优化。
使用方法 从玩家输入到游戏中显示效果,需要两个系统来实现效果,拿移动来举例
MoveInputSystem
:在客户端上运行,获取玩家输入
添加到GhostInputSystemGroup
中,该Group只存在于Client World中
需要监听输入,并将输入转换成IInputComponentData
组件上的数据,存放在该客户端GhostOwnerIsLocal
的Ghost Entity上
unity会自动同步该数据,即使你没有使用[GhostField]
修饰数据
MoveSystem
:同时在客户端和服务端上运行,执行玩家的命令
添加到PredictedSimulationSystemGroup
中,客户端和服务端都会运行,并且会模拟预测数据
unity将数据从客户端同步到服务端之后,就可以捕获到数据并执行命令了
传递普通数据: 设置需要传递的数据
1 2 3 4 5 6 public struct ChampMoveTargetPosition : IInputComponentData{ public float3 Value; }
将system添加到GhostInputSystemGroup
中并。然后设置数值,系统会自动帮我们实现同步
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 [UpdateInGroup(typeof(GhostInputSystemGroup)) ] public partial class ChampMoveInputSystem : SystemBase { protected override void OnStartRunning () { PlayerInputSystem.Instance.SelectMovePositionEvent += OnSelectMovePosition; } protected override void OnStopRunning () { PlayerInputSystem.Instance.SelectMovePositionEvent -= OnSelectMovePosition; } private void OnSelectMovePosition (InputAction.CallbackContext obj ) { var champEntity = SystemAPI.GetSingletonEntity<OwnerChampTag>(); EntityManager.SetComponentData(champEntity, new ChampMoveTargetPosition() { Value = closestHit.Position }); } }
定义IInputComponentData
和InputEvent
,并将其烘焙到Entitiy上
1 2 3 4 5 6 public struct AbilityInput : IInputComponentData{ public InputEvent AoeAbility; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [UpdateInGroup(typeof(GhostInputSystemGroup)) ] public partial class AbilityInputSystem : SystemBase { protected override void OnUpdate () { var newAbilityInput = new AbilityInput(); if (PlayerInputSystem.Instance.QKeyWasPressedThisFrame) { newAbilityInput.AoeAbility.Set(); } foreach (var abilityInput in SystemAPI.Query<RefRW<AbilityInput>>()) { abilityInput.ValueRW = newAbilityInput; } } }
InputEvent
类似一个计数器,客户端使用Set()
之后。 服务端检测到其大于0,那么就会判定为输入了,同时将其复原成0。 这样就避免了服务器帧率没有客户端高,而引发的监听不到输入的问题了。InputEvent
常用在类似按钮的触发事件,移动摇杆这类常用的移动输入直接使用数值就OK了
这里说个题外话:关于var newAbilityInput = new AbilityInput();
因为结构体是值类型,所以在OnUpdate
执行完后会直接释放掉,不涉及垃圾回收(也就是GC)
由于栈的分配效率非常高效,即使OnUpdate
每帧都在调用,数据量不大的话也不会对性能有影响
射线碰撞检测 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 private void OnSelectMovePosition (){ var collisionWorld = SystemAPI.GetSingleton<PhysicsWorldSingleton>().CollisionWorld; var cameraEntity = SystemAPI.GetSingletonEntity<MainCameraTag>(); var mainCamera = EntityManager.GetComponentObject<MainCamera>(cameraEntity).Value; var mousePosition = Input.mousePosition; mousePosition.z = 100f ; var worldPosition = mainCamera.ScreenToWorldPoint(mousePosition); var selectionInput = new RaycastInput() { Start = mainCamera.transform.position, End = worldPosition, Filter = new CollisionFilter() { BelongsTo = 1 << 5 , CollidesWith = 1 << 0 }; }; if (collisionWorld.CastRay(selectionInput, out RaycastHit closestHit)) { var champEntity = SystemAPI.GetSingletonEntity<OwnerChampTag>(); EntityManager.SetComponentData(champEntity, new ChampMoveTargetPosition() { Value = closestHit.Position }); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 var hits = new NativeList<DistanceHit>(Allocator.TempJob);if (CollisionWorld.OverlapSphere(transform.Position, targetRadius.Value, ref hits, CollisionFilter)){ var closestDistance = float .MaxValue; var closestEntity = Entity.Null; foreach (var hit in hits) { if (hit.Distance < closestDistance) { closestDistance = hit.Distance; closestEntity = hit.Entity; } } targetEntity.Value = closestEntity; } hits.Dispose();
销毁 步骤:
在预制体上烘焙DestoryOnTimer
,预设 物体多久后销毁
客户端和服务端都会运行的InitializeDestoryOnTimerSystem
捕获拥有DestoryOnTimer
组件的实体,并计算 该实体在多少Tick的时候销毁,将计算的事件存储在DestroyAtTick
的Ghost数据上
服务器计算时间不能以deltaTime
(不够稳定)或frame
(客户端和服务端会不一致)
而是使用ServerTick
,说简单点其实频率,默认为60Tick/秒,与普通的帧还不太一样
客户端和服务端都会运行的DestroyOnTimerSystem
捕获拥有DestroyAtTick
组件的实体,并判断 该实体是否已达到销毁时间,达到销毁时间后挂载DestroyEntityTag
标签
该system只是添加标签,并不是直接销毁
为什么不直接销毁?因为凡是涉及到客户端表现的都需要做预测 ,也就是得放置在PredictedSimulationSystemGroup
中
放置在PredictedSimulationSystemGroup
(预测组——服务端和客户端都会预测) 中的DestroyEntitySystem
捕获拥有DestroyEntityTag
标签的实体进行处理 。暂时没有很好的办法使客户端和服务端同时进行销毁,当前的做法如下:
服务端:直接摧毁
客户端:将物体移动到不可见的位置,并等待服务器同步
从下图可看出,有一半的次数客户端要晚于服务端摧毁,并且实际运用起来可能不止一半
共涉及两个组件、一个标签、三个系统
1 2 3 4 public struct DestroyAtTick : IComponentData{ [GhostField ] public NetworkTick Value; }
1 public struct DestroyEntityTag : IComponentData { }
步骤一:预设烘焙 1 2 3 4 public struct DestroyOnTimer : IComponentData{ public float Value; }
步骤二:计算销毁时间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public partial struct InitializeDestroyOnTimerSystem : ISystem{ public void OnUpdate (ref SystemState state ) { var ecb = new EntityCommandBuffer(Allocator.Temp); var simulationTickRate = NetCodeConfig.Global.ClientServerTickRate.SimulationTickRate; var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick; foreach (var (destroyOnTimer, entity) in SystemAPI.Query<DestroyOnTimer>().WithNone<DestroyAtTick>() .WithEntityAccess()) { var lifetimeInTicks = (uint )(destroyOnTimer.Value * simulationTickRate); var targetTick = currentTick; targetTick.Add(lifetimeInTicks); ecb.AddComponent(entity, new DestroyAtTick { Value = targetTick }); } ecb.Playback(state.EntityManager); } }
步骤三:判断是否达到销毁Tick 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public partial struct DestroyOnTimerSystem : ISystem{ public void OnUpdate (ref SystemState state ) { var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>(); var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged); var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick; foreach (var (destroyAtTick, entity) in SystemAPI.Query<DestroyAtTick>().WithAll<Simulate>() .WithNone<DestroyEntityTag>().WithEntityAccess()) { if (currentTick.Equals(destroyAtTick.Value) || currentTick.IsNewerThan(destroyAtTick.Value)) ecb.AddComponent<DestroyEntityTag>(entity); } } }
步骤四:处理服务端和客户端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 [UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderLast = true) ] public partial struct DestroyEntitySystem : ISystem{ public void OnUpdate (ref SystemState state ) { var networkTime = SystemAPI.GetSingleton<NetworkTime>(); var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>(); var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged); foreach (var (transform, entity) in SystemAPI.Query<RefRW<LocalTransform>>() .WithAll<DestroyEntityTag, Simulate>().WithEntityAccess()) { if (state.World.IsServer()) ecb.DestroyEntity(entity); else transform.ValueRW.Position = new float3(999f , 999f , 999f ); } } }
伤害 步骤:
预设烘焙 :
可造成伤害实体(飞行物、刀剑等):
DamageOnTrigger
:设置伤害量
AlreadyDamagedEntity
:存储造成伤害的entity,防止重复造成伤害
可受伤的实体(英雄、防御塔、小兵等):
DamageBufferElement
:存储伤害值
DamageThisTick
:记录在服务器的多少帧受到多少伤害,并将伤害应用到实体上
监听 碰撞:使用ITriggerEventsJob
监听碰撞,并执行以下处理
将伤害值添加到受击者的DamageBufferElement
伤害缓存池中
将受击者添加到攻击者的AlreadyDamagedEntity
中,防止重复计算伤害
计算 伤害:捕获DamageBufferElement
并将其数值和当前Tick存储在DamageThisTick
中
实施 伤害:捕获DamageThisTick
并判断是否是当前帧,只有是当前帧的伤害才实施
共涉及4个组件和三个系统,所有系统都是在PredictedSimulationSystemGroup
预测组上运行的
第一步:预设烘焙 1 2 3 4 public struct DamageOnTrigger : IComponentData{ public int Value; }
1 2 3 4 public struct AlreadyDamagedEntity : IBufferElementData{ public Entity Value; }
1 2 3 4 5 [GhostComponent(PrefabType = GhostPrefabType.AllPredicted) ] public struct DamageBufferElement : IBufferElementData{ public int Value; }
1 2 3 4 5 6 7 [GhostComponent(PrefabType = GhostPrefabType.AllPredicted, OwnerSendType = SendToOwnerType.SendToNonOwner) ] public struct DamageThisTick : ICommandData { public NetworkTick Tick { get ; set ; } public int Value; }
第二步:监听碰撞 注意:使用的是ITriggerEventsJob
而不是普通的IJobEntity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 [UpdateInGroup(typeof(PhysicsSystemGroup)) ] [UpdateAfter(typeof(PhysicsSimulationGroup)) ] public partial struct DamageOnTriggerSystem : ISystem{ public void OnUpdate (ref SystemState state ) { var simulationSingleton = SystemAPI.GetSingleton<SimulationSingleton>(); state.Dependency = job.Schedule(simulationSingleton, state.Dependency);*/ bool isClient = state.World.IsClient(); var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>(); var damageOnTriggerJob = new DamageOnTriggerJob { DamageOnTriggerLookup = SystemAPI.GetComponentLookup<DamageOnTrigger>(true ), TeamLookup = SystemAPI.GetComponentLookup<MobaTeam>(true ), AlreadyDamagedLookup = SystemAPI.GetBufferLookup<AlreadyDamagedEntity>(true ), DamageBufferLookup = SystemAPI.GetBufferLookup<DamageBufferElement>(true ), ECB = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged), isClient = isClient, }; var simulationSingleton = SystemAPI.GetSingleton<SimulationSingleton>(); state.Dependency = damageOnTriggerJob.Schedule(simulationSingleton, state.Dependency); } } public struct DamageOnTriggerJob : ITriggerEventsJob { [ReadOnly ] public ComponentLookup<MobaTeam> TeamLookup; [ReadOnly ] public ComponentLookup<DamageOnTrigger> DamageOnTriggerLookup; [ReadOnly ] public BufferLookup<DamageBufferElement> DamageBufferLookup; [ReadOnly ] public BufferLookup<AlreadyDamagedEntity> AlreadyDamagedLookup; public EntityCommandBuffer ECB; public void Execute (TriggerEvent triggerEvent ) { Entity damageDealingEntity; Entity damageReceivingEntity; if (DamageBufferLookup.HasBuffer(triggerEvent.EntityA) && DamageOnTriggerLookup.HasComponent(triggerEvent.EntityB)) { damageReceivingEntity = triggerEvent.EntityA; damageDealingEntity = triggerEvent.EntityB; } else if (DamageOnTriggerLookup.HasComponent(triggerEvent.EntityA) && DamageBufferLookup.HasBuffer(triggerEvent.EntityB)) { damageDealingEntity = triggerEvent.EntityA; damageReceivingEntity = triggerEvent.EntityB; } else return ; if (TeamLookup.TryGetComponent(damageDealingEntity, out var damageDealingTeam) && TeamLookup.TryGetComponent(damageReceivingEntity, out var damageReceivingTeam)) { if (damageDealingTeam.Value == damageReceivingTeam.Value) return ; } var alreadyDamagedBuffer = AlreadyDamagedLookup[damageDealingEntity]; foreach (AlreadyDamagedEntity alreadyDamagedEntity in alreadyDamagedBuffer) if (alreadyDamagedEntity.Value.Equals(damageReceivingEntity)) return ; var damageOnTrigger = DamageOnTriggerLookup[damageDealingEntity]; ECB.AppendToBuffer(damageReceivingEntity, new DamageBufferElement {Value = damageOnTrigger.Value }); ECB.AppendToBuffer(damageDealingEntity, new AlreadyDamagedEntity { Value = damageReceivingEntity }); } }
第三步:计算伤害 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 [UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderLast = true) ] public partial struct CalculateFrameDamageSystem : ISystem{ public void OnUpdate (ref SystemState state ) { var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick; foreach (var (damageBuffer, damageThisTickBuffer) in SystemAPI .Query<DynamicBuffer<DamageBufferElement>, DynamicBuffer<DamageThisTick>>() .WithAll<Simulate>()) { if (damageBuffer.IsEmpty) { damageThisTickBuffer.AddCommandData(new DamageThisTick { Tick = currentTick, Value = 0 }); } else { var totalDamage = 0 ; if (damageThisTickBuffer.GetDataAtTick(currentTick, out var damageThisTick)) { totalDamage = damageThisTick.Value; } foreach (var damage in damageBuffer) { totalDamage += damage.Value; } damageThisTickBuffer.AddCommandData(new DamageThisTick { Tick = currentTick, Value = totalDamage }); damageBuffer.Clear(); } } } }
第四步:实施伤害 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 [UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderLast = true) ] [UpdateAfter(typeof(CalculateFrameDamageSystem)) ] public partial struct ApplyDamageSystem : ISystem{ public void OnUpdate (ref SystemState state ) { var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick; var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (currentHitPoints, damageThisTickBuffer, entity) in SystemAPI .Query<RefRW<CurrentHitPoints>, DynamicBuffer<DamageThisTick>>().WithAll<Simulate>() .WithEntityAccess()) { if (!damageThisTickBuffer.GetDataAtTick(currentTick, out DamageThisTick damageThisTick)) continue ; if (damageThisTick.Tick != currentTick) continue ; currentHitPoints.ValueRW.Value -= damageThisTick.Value; if (currentHitPoints.ValueRO.Value <= 0 ) { ecb.AddComponent<DestroyEntityTag>(entity); } } ecb.Playback(state.EntityManager); } }
技能CD 在释放技能前判断是否达到CD
1 2 3 4 public struct AbilityCooldownTicks : IComponentData{ public uint AoeAbility; }
1 2 3 4 5 6 [GhostComponent(PrefabType = GhostPrefabType.AllPredicted) ] public struct AbilityCooldownTargetTicks : ICommandData{ public NetworkTick Tick { get ; set ; } public NetworkTick AoeAbility; }
为了避免服务器跳帧,还需要将前几Tick的数据来比较
下面的代码可能会让人很困惑,为什么要多此两举的,既要将释放技能的时机向后移动1Tick,又要在释放技能的时候把前几Tick的数据来做比较。但这种方法是经过社区测试,而得出的一个相对较好的判断技能CD的方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 var isOnCooldown = true ;var curTargetTicks = new AbilityCooldownTargetTicks();for (var i = 0u ; i < networkTime.SimulationStepBatchSize; i++){ var testTick = currentTick; testTick.Subtract(i); if (!aoe.CooldownTargetTicks.GetDataAtTick(testTick, out curTargetTicks)) { curTargetTicks.AoeAbility = NetworkTick.Invalid; } if (curTargetTicks.AoeAbility == NetworkTick.Invalid || !curTargetTicks.AoeAbility.IsNewerThan(currentTick)) { isOnCooldown = false ; break ; } } if (isOnCooldown) continue ;if (输入检测){ if (state.WorldUnmanaged.IsServer()) continue ; var newCooldownTargetTick = currentTick; newCooldownTargetTick.Add(aoe.CooldownTicks); curTargetTicks.AoeAbility = newCooldownTargetTick; var nextTick = currentTick; nextTick.Add(1u ); curTargetTicks.Tick = nextTick; aoe.CooldownTargetTicks.AddCommandData(curTargetTicks); }
UI 与普通的组件类似,但有一些特殊规则
无法烘焙
当你Destroy包含cleanup的entity时,并不会真正的删除他,而是会移除掉他身上所有非cleanup的组件。除非你将cleanup也移除,才能真正的destroy该netity
通常用在不同系统的销毁处理上,比如角色和血条他们两并不是父子关系(一个在Entity,一个在GameObject),而是引用关系
除非我们在删除entity前将血条也删除了,但是这样代码不易维护,并且该entity并不一定有血条
血条系统 组件 1 2 3 4 public struct HealthBarOffset : IComponentData{ public float3 Value; }
烘焙 每个可受伤的角色都烘焙一个位置偏差值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class HealthBarUIReference : ICleanupComponentData { public GameObject Value; } public class HitPointAuthoring : MonoBehaviour { public int maxHitPoints; public Vector3 healthBarOffset; private class Baker : Baker <HitPointAuthoring > { public override void Bake (HitPointAuthoring authoring ) { var entity = GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity, new CurrentHitPoints{Value = authoring.maxHitPoints}); AddComponent(entity, new MaxHitPoints{Value = authoring.maxHitPoints}); AddComponent<DamageBufferElement>(entity); AddComponent<DamageThisTick>(entity); AddComponent(entity, new HealthBarOffset() { Value = authoring.healthBarOffset }); } } }
直接在场景创建一个单例,保存血条预制体,方便我们创建血条
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class UIPrefabs : IComponentData { public GameObject HealthBar; } public GameObject healthBarPrefab;private class Baker : Baker <MobaPrefabsAuthoring >{ public override void Bake (MobaPrefabsAuthoring authoring ) { var prefabContainerEntity = GetEntity(TransformUsageFlags.None); AddComponentObject(prefabContainerEntity, new UIPrefabs() { HealthBar = authoring.healthBarPrefab }); } }
HealthBarUIReference
:场景中每个可受伤的entity都有用一个该组件
UIPrefabs
:该组件整个场景中只有一个,保存UI的预制体方便创建UI
系统 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 [UpdateAfter(typeof(TransformSystemGroup)) ] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation) ] public partial struct HealthBarSystem : ISystem{ public void OnUpdate (ref SystemState state ) { var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>(); var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged); foreach (var (transform, healthBarOffset, maxHitPoints, entity) in SystemAPI.Query<LocalTransform, HealthBarOffset, MaxHitPoints>() .WithNone<HealthBarUIReference>().WithEntityAccess()) { var healthBarPrefab = SystemAPI.ManagedAPI.GetSingleton<UIPrefabs>().HealthBar; var spawnPosition = transform.Position + healthBarOffset.Value; var newHealthBar = Object.Instantiate(healthBarPrefab, spawnPosition, quaternion.identity); SetHealthBar(newHealthBar, maxHitPoints.Value, maxHitPoints.Value); ecb.AddComponent(entity, new HealthBarUIReference() { Value = newHealthBar }); } foreach (var (transform, healthBarOffset, currentHitPoints, maxHitPoints, healthBarUI) in SystemAPI .Query<LocalTransform, HealthBarOffset, CurrentHitPoints, MaxHitPoints, HealthBarUIReference>()) { var healthBarPosition = transform.Position + healthBarOffset.Value; healthBarUI.Value.transform.position = healthBarPosition; SetHealthBar(healthBarUI.Value, currentHitPoints.Value, maxHitPoints.Value); } foreach (var (healthBarUI, entity) in SystemAPI.Query<HealthBarUIReference>() .WithNone<LocalTransform>().WithEntityAccess()) { Object.Destroy(healthBarUI.Value); ecb.RemoveComponent<HealthBarUIReference>(entity); } } private void SetHealthBar (GameObject healthBarCanvasObject, int curHitPoints, int maxHitPoints ) { var healthBarSlider = healthBarCanvasObject.GetComponentInChildren<Slider>(); healthBarSlider.minValue = 0 ; healthBarSlider.maxValue = maxHitPoints; healthBarSlider.value = curHitPoints; } }
技能冷却 使用单例控制图标的进度,这里就展示代码了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation) ] public partial struct AbilityCooldownUISystem : ISystem{ public void OnUpdate (ref SystemState state ) { var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick; var abilityCooldownUIController = AbilityCooldownUIController.Instance; foreach (var (cooldownTargetTicks, abilityCooldownTicks) in SystemAPI .Query<DynamicBuffer<AbilityCooldownTargetTicks>, AbilityCooldownTicks>()) { if (!cooldownTargetTicks.GetDataAtTick(currentTick, out var curTargetTicks)) { curTargetTicks.AoeAbility = NetworkTick.Invalid; curTargetTicks.SkillShotAbility = NetworkTick.Invalid; } if (curTargetTicks.AoeAbility == NetworkTick.Invalid || currentTick.IsNewerThan(curTargetTicks.AoeAbility)) { abilityCooldownUIController.UpdateAoeMask(0f ); } else { var aoeRemainTickCount = curTargetTicks.AoeAbility.TickIndexForValidTick - currentTick.TickIndexForValidTick; var fillAmount = (float )aoeRemainTickCount / abilityCooldownTicks.AoeAbility; abilityCooldownUIController.UpdateAoeMask(fillAmount); } } } }
退出服务器 在客户端或者GameObject世界执行如下操作
获取到NetworkStreamConnection
实体,并添加NetworkStreamRequestDisconnect
组件
销毁世界,并加载其他场景
1 2 3 4 var networkConnection = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();SystemAPI.GetComponent<NetworkStreamRequestDisconnect>(networkConnection); World.DisposeAllWorlds(); SceneManager.LoadScene(0 );
虚拟客户端 创建
[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 [WorldSystemFilter(WorldSystemFilterFlags.ThinClientSimulation) ] public partial struct ThinClientEntrySystem : ISystem{ public void OnUpdate (ref SystemState state ) { state.Enabled = false ; var thinClientDummy = state.EntityManager.CreateEntity(); state.EntityManager.AddComponent<ChampMoveTargetPosition>(thinClientDummy); state.EntityManager.AddBuffer<InputBufferData<ChampMoveTargetPosition>>(thinClientDummy); 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 9 public 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 17 var connectionEntity = SystemAPI.GetSingletonEntity<NetworkId>(); SystemAPI.SetComponent(connectionEntity, new CommandTarget { targetEntity = thinClientDummy }); var connectionId = SystemAPI.GetSingleton<NetworkId>().Value;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 });