知乎其他人经验 这个人的代码老旧,部分已经对不上,如角色网络对象和显示分离做法已经变了
官方项目代码介绍,更复杂,看起来累,但避免了货不对板
同步方式很重要,先看官方这个才能看懂代码
在StartUp做游戏初始化,然后再到开始场景
切换场景不销毁的对象,都用了DontDestroyOnLoad
NetworkManager是NetCode的控制器,挂在全局对象上
ConnectionManager是BossRoom自己写的组件,声明并且注入了游戏状态机,游戏状态改变时调ChangeState
ClientConnectingState 进入这个状态会起一个异步任务,连接服务器ConnectClientAsync
玩家连接,断开服务器,都通过这个管理
GameDataSource 里存行为列表
using Action = Unity.BossRoom.Gameplay.Actions.Action;
[Tooltip("All Action prototype scriptable objects should be slotted in here")]
[SerializeField]
// 这就是BossRoom的所有技能,都做成asset配置,存到这里了
private Action[] m_ActionPrototypes;
SceneLoader里SceneLoaderWrapper是BossRoom实现的类
NetCode自带了一个场景管理NetworkSceneManager,并且官方希望刚开始用NetCode的人都要用这个场景管理
OnSceneEvent附带挺多事件的,加载过程根据这几个状态做进度可视化,直到SynchronizeComplete才做逻辑初始化,可避免掉虚空
对服务器而言玩家只是个网络连接,对客户端而言玩游戏玩的是输入控制
玩家不等于游戏角色,只不过玩家的输入控制,影响了游戏角色
每个网络连接会绑定一个永久的玩家Prefab,BossRoom里叫PersistentPlayer,它拥有NetworkObject组件,定义在NetworkManager里面,这个东西不销毁
Startup场景初始化游戏,没有角色也不需要输入控制
MainMenu场景已经初始化了游戏,需要指定服务器,输入控制实际上控制的是配置和网络连接,没角色
CharSelect场景连上了服务器,但是也不需要角色,输入控制就是组队的UI交互
BossRoom场景才真正有了游戏角色,根据网络连接创建角色通知客户端,客户端再判断是不是玩家,是不是自己,如果是自己,就绑定输入控制
void OnLoadEventCompleted(string sceneName, LoadSceneMode loadSceneMode, List<ulong> clientsCompleted, List<ulong> clientsTimedOut)
{
if (!InitialSpawnDone && loadSceneMode == LoadSceneMode.Single)
{
InitialSpawnDone = true;
// 为每个网络连接创建游戏角色并初始化位置
foreach (var kvp in NetworkManager.Singleton.ConnectedClients)
{
//ServerBossRoomState定义了PlayerPrefab,创建玩家角色的时候就创建这个prefab
SpawnPlayer(kvp.Key, false);
}
}
}
// 上面的SpawnPlayer常规执行创建,关键是最后
// NetworkObject创建好,要调用SpawnWithOwnership,把自己注册进NetworkSpawnManager,注册完成会回调各个组件的OnNetworkSpawn做初始化,游戏就可以开始了
// spawn players characters with destroyWithScene = true
newPlayer.SpawnWithOwnership(clientId, true);
guid不是guide,不是引导!!!!是职业ID
NetworkAvatarGuidState的作用根据guid初始化角色信息,因此它专门建了个asset配置AvatarRegistry,管理所有的角色配置
///
/// NetworkBehaviour component to send/receive GUIDs from server to clients.
///
public class NetworkAvatarGuidState : NetworkBehaviour
{
//这个GUID会随机初始化,然后选角色的后更新
[FormerlySerializedAs("AvatarGuidArray")]
[HideInInspector]
public NetworkVariable<NetworkGuid> AvatarGuid = new NetworkVariable<NetworkGuid>();
[SerializeField]
AvatarRegistry m_AvatarRegistry;
}
//它被声明在PersistentPlayer里面,先是初始化随机一个值
[RequireComponent(typeof(NetworkObject))]
public class PersistentPlayer : NetworkBehaviour
{
[SerializeField]
PersistentPlayerRuntimeCollection m_PersistentPlayerRuntimeCollection;
[SerializeField]
NetworkNameState m_NetworkNameState;
[SerializeField]
NetworkAvatarGuidState m_NetworkAvatarGuidState;
public NetworkNameState NetworkNameState => m_NetworkNameState;
public NetworkAvatarGuidState NetworkAvatarGuidState => m_NetworkAvatarGuidState;
}
// 选角色完成的时候会把角色信息存进去,由此可见,这个persistentPlayer,就是用来玩家数据并传递的
void SaveLobbyResults()
{
foreach (NetworkCharSelection.LobbyPlayerState playerInfo in networkCharSelectioLobbyPlayers)
{
var playerNetworkObject = NetworkManager.Singleton.SpawnManageGetPlayerNetworkObject(playerInfo.ClientId)
if (playerNetworkObject && playerNetworkObject.TryGetComponent(ouPersistentPlayer persistentPlayer))
{
// pass avatar GUID to PersistentPlayer
// it'd be great to simplify this with something like NetworkScriptableObjects :(
persistentPlayer.NetworkAvatarGuidState.AvatarGuid.Value =
networkCharSelection.AvatarConfiguration[playerInfo.SeatIdx].GuiToNetworkGuid();
}
}
}
// BossRoom场景加载完成,SpawnPlayer的时候,将这个值传给PlayerAvatar里的同名组件
// 这个PlayerAvatar只是战斗用的对象,可以被销毁,虽然也有个NetworkAvatarGuidState,但这个state跟对象一样是一次性的,并且因为是新组件,所以m_Avatar为空,
// pass character type from persistent player to avatar
var networkAvatarGuidStateExists =
newPlayer.TryGetComponent(out NetworkAvatarGuidState networkAvatarGuidState);
Assert.IsTrue(networkAvatarGuidStateExists,
$"NetworkCharacterGuidState not found on player avatar!");
// if reconnecting, set the player's position and rotation to its previous state
if (lateJoin)
{
SessionPlayerData? sessionPlayerData = SessionManager<SessionPlayerData>.Instance.GetPlayerData(clientId);
if (sessionPlayerData is { HasCharacterSpawned: true })
{
physicsTransform.SetPositionAndRotation(sessionPlayerData.Value.PlayerPosition, sessionPlayerData.Value.PlayerRotation);
}
}
networkAvatarGuidState.AvatarGuid.Value =
persistentPlayer.NetworkAvatarGuidState.AvatarGuid.Value;
// ServerCharacter组件引用了这个state,职业信息CharacterClass的数据从这个state拿
// 因为ServerCharacter引用的GuidState是新创建的,所以m_Avatar是空,所以引用RegisteredAvatar的时候会以实际选择的guid初始化,职业就对上了
public CharacterClass CharacterClass
{
get
{
if (m_CharacterClass == null)
{
m_CharacterClass = m_State.RegisteredAvatar.CharacterClass;
return m_CharacterClass;
set => m_CharacterClass = value;
}
NetworkAvatarGuidState m_State;
//这个state存了个角色的avatar
[SerializeField]
AvatarRegistry m_AvatarRegistry
Avatar m_Avatar
public Avatar RegisteredAvatar
{
get
{
if (m_Avatar == null)
{
//这时候的guid已经被设置为玩家选择的职业ID了
RegisterAvatar(AvatarGuid.Value.ToGuid());
return m_Avatar;
}
}
//根据guid注册avatar,serverCharacter的职业在这里设置
void RegisterAvatar(Guid guid)
{
if (guid.Equals(Guid.Empty))
{
// not a valid Guid
return;
// based on the Guid received, Avatar is fetched from AvatarRegistry
if (!m_AvatarRegistry.TryGetAvatar(guid, out var avatar))
{
Debug.LogError("Avatar not found!");
return;
if (m_Avatar != null)
{
// already set, this is an idempotent call, we don't want to Instantiate twice
return;
m_Avatar = avatar
if (TryGetComponent<ServerCharacter>(out var serverCharacter))
{
//职业信息写回ServerCharacter
serverCharacter.CharacterClass = avatar.CharacterClass;
}
}
// 最终在组件ClientAvatarGuidHandler组件里创建了客户端能看到的模型
// 这个函数服务器不执行,毕竟服务器不需要跑渲染
// spawn avatar graphics GameObject
Instantiate(m_NetworkAvatarGuidState.RegisteredAvatar.Graphics, m_GraphicsAnimattransform);
血量HP,全称是HitPoints,即打击次数,HP100就是能挨100刀的意思
游戏里血量也是有一个状态管理,角色的预制体是PlayerAvatar,组件叫NetworkHelthState
![血量组件](https://img-blog.csdnimg.cn/47a60b08ea6e428785ac4af7a2893b6d.png
需要单独同步的数据类型是NetworkVariable,这个类型的字段在修改时会自动同步,并且能监听事件OnValueChanged
NetworkHealthState.cs代码一共没几行,也就是定义HP,和HP变化的事件
[HideInInspector]
public NetworkVariable<int> HitPoints = new NetworkVariable<int>();
玩家受伤同样有个组件,DamageReceiver监听,血量变化时触发DamageReceived事件
ServerCharacter即服务器角色组件,监听受伤事件,做血量更新计算
这里也定义了HitPoints,实际上只能算是引用,值还是NetHealthState那边的
///
/// Current HP. This value is populated at startup time from CharacterClass data.
///
public int HitPoints
{
get => NetHealthState.HitPoints.Value;
private set => NetHealthState.HitPoints.Value = value;
}
BossRoom的代码命名跟我的习惯不一样,他这里HP是血量变化的增量,HitPoints才是玩家的当前血量
我的习惯是addHp和curHp
void ReceiveHP(ServerCharacter inflicter, int HP)
{
//加血和掉血,还处理了buff加成
//to our own effects, and modify the damage or healing as appropriate. But in this game, we just take it straight.
if (HP > 0)
{
m_ServerActionPlayer.OnGameplayActivity(Action.GameplayActivity.Healed);
float healingMod = m_ServerActionPlayer.GetBuffedValue(Action.BuffableValue.PercentHealingReceived);
HP = (int)(HP * healingMod);
}
else
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
// Don't apply damage if god mode is on
if (NetLifeState.IsGodMode.Value)
{
return;
}
#endif
m_ServerActionPlayer.OnGameplayActivity(Action.GameplayActivity.AttackedByEnemy);
float damageMod = m_ServerActionPlayer.GetBuffedValue(Action.BuffableValue.PercentDamageReceived);
HP = (int)(HP * damageMod);
serverAnimationHandler.NetworkAnimator.SetTrigger("HitReact1");
}
//HP更新,因为set函数被重写到NetHealthState那里赋值了,会触发同步
HitPoints = Mathf.Clamp(HitPoints + HP, 0, CharacterClass.BaseHP.Value);
if (m_AIBrain != null)
{
//let the brain know about the modified amount of damage we received.
m_AIBrain.ReceiveHP(inflicter, HP);
}
//判断死亡,LifeState跟HP是类似的组件管理,忽略
客户端监听Prefab的HitPoints变化,触发UI的更新
// 主角的血量更新
m_OwnedServerCharacter.NetHealthState.HitPoints.OnValueChanged += SetHeroHealth;
// 队友的血量更新
serverCharacter.NetHealthState.HitPoints.OnValueChanged += (int previousValue, int newValue) =>
{
SetAllyHealth(id, newValue);
};
选人界面的networkCharSelection.LobbyPlayers存放着所有玩家的选角信息,用来做选人的校验和同步
private NetworkList m_LobbyPlayers;
OnListChanged是NetworkList定义的一个委托,当玩家信息变化时触发事件,游戏接入处理了选角色时的UI响应
具体的网络传输疑似被封装进INetworkUpdateSystem里面,VS不能调试进入
跟踪ChangeSeatServerRpc发现,非主机不会走进来,说明这个函数是走了网络,到主机上执行的(实际就是典型的ServerRpc用法)
NetworkListEvent针对NetworkList定义了增删改查操作,业务通过泛型传具体的结构体数据,然后注册响应事件做处理,定制性还可以
实际代码客户端处理是先对玩家遍历更新,然后再对主角单独处理ready界面
// 遍历列表,找到客户端的id
// now let's find our local player in the list and update the character/info box appropriately
int localPlayerIdx = -1;
for (int i = 0; i < m_NetworkCharSelection.LobbyPlayers.Count; ++i)
{
if (m_NetworkCharSelection.LobbyPlayers[i].ClientId == NetworkManager.Singleton.LocalClientId)
{
localPlayerIdx = i;
break;
}
}
客户端的ready按钮变化,是监听了服务器消息,下断点可以看到 NetworkList`1:ReadDelta 发生了Value修改,通过OnLobbyPlayerStateChanged回调到UI
Void Unity.BossRoom.Gameplay.UI.UICharSelectClassInfoBox:SetLockedIn (Boolean)+0x1 atC:\sandbox\unity\TestBossRoom\Assets\Scripts\Gameplay\UI\UICharSelectClassInfoBox.cs[62:13-62:77] C#
Void Unity.BossRoom.Gameplay.GameState.ClientCharSelectState:UpdateCharacterSelection(SeatState, Int32)+0x15f atC:\sandbox\unity\TestBossRoom\Assets\Scripts\Gameplay\GameState\ClientCharSelectState.cs[288:25-288:59] C#
>Void Unity.BossRoom.Gameplay.GameState.ClientCharSelectState:OnLobbyPlayerStateChanged (NetworkListEvent`1)+0xeb at C:\sandbox\unity\TestBossRoom\Assets\Scripts\Gameplay\GameState\ClientCharSelectState.cs:[228:17-228:166] C#
Void Unity.Netcode.NetworkList`1:ReadDelta (FastBufferReader, Boolean)+0x47b at \Library\PackageCache\[email protected]\Runtime\NetworkVariable\Collections\NetworkList.cs:[312:33-318:36] C#
Void Unity.Netcode.NetworkVariableDeltaMessage:Handle (NetworkContext)+0x2c3 at \Library\PackageCache\[email protected]\Runtime\Messaging\Messages\NetworkVariableDeltaMessage.cs:[197:25-197:107] C#
Void Unity.Netcode.MessagingSystem:ReceiveMessage (FastBufferReader, NetworkContext,MessagingSystem)+0xe2 at .\Library\PackageCache\[email protected]\Runtime\Messaging\MessagingSystem.cs:[511:17-511:45] C#
Void Unity.Netcode.MessagingSystem:HandleMessage (MessageHeader, FastBufferReader, UInt64,Single, Int32)+0x13c at .\Library\PackageCache\[email protected]\Runtime\Messaging\MessagingSystem.cs:[384:25-384:67] C#
Void Unity.Netcode.MessagingSystem:ProcessIncomingMessageQueue ()+0x32 at \Library\PackageCache\[email protected]\Runtime\Messaging\MessagingSystemcs:[404:17-404:122] C#
Void Unity.Netcode.NetworkManager:OnNetworkEarlyUpdate ()+0x66 at .\Library\PackageCache\[email protected]\Runtime\Core\NetworkManager.cs:[1600:13-1600:59] C#
Void Unity.Netcode.NetworkManager:NetworkUpdate (NetworkUpdateStage)+0x18 at \Library\PackageCache\[email protected]\Runtime\Core\NetworkManager.cs[1532:21-1532:44] C#
Void Unity.Netcode.NetworkUpdateLoop:RunNetworkUpdateStage (NetworkUpdateStage)+0x2f at \Library\PackageCache\[email protected]\Runtime\Core\NetworkUpdateLoop.cs[185:17-185:51] C#
Void <>c:b__0_0 ()+0x1 at .\Library\PackageCache\[email protected]\Runtime\Core\NetworkUpdateLoop.cs:[208:44-208:97] C#
NetworkBehaviour官方文档 必看代码简单
ClientCharacter.cs继承自NetworkBehaviour,复写方法OnNetworkSpawn
动态创建先OnNetworkSpawn再Start,静态场景的对象先Start再OnNetworkSpawn,是个坑
// 如果不是NPC,那就是玩家
if (!m_ServerCharacter.IsNpc)
// 如果是主角,就给gameObject加上摄像机,再注册输入的监听
if (m_ServerCharacter.IsOwner)
{
ActionRequestData data = new ActionRequestData { ActionID = GameDataSource.Instance.GeneralTargetActionPrototActionID };
m_ClientActionViz.PlayAction(ref data);
gameObject.AddComponent<CameraController>
if (m_ServerCharacter.TryGetComponent(out ClientInputSender inputSender))
{
// TODO: revisit; anticipated actions would play twice on the host
if (!IsServer)
{
inputSender.ActionInputEvent += OnActionInput;
}
inputSender.ClientMoveEvent += OnMoveInput;
}
}
BossRoom的协议包基类是ActionRequestData,继承自INetworkSerializable,定义了很多非必填字段,又根据flag做流量优化,如果字段是默认值,就算缺省
游戏定义了一种资源类型Action,列举了玩家行为,基本可以认为是同步包协议的定义
//发包函数
void SendInput(ActionRequestData action)
{
ActionInputEvent?.Invoke(action);
m_ServerCharacter.RecvDoActionServerRPC(action);
}
//组包发送
var data = new ActionRequestData();
PopulateSkillRequest(k_CachedHit[0].point, actionID, ref data);
SendInput(data);
//施放需要指定目标或范围指示器的技能,也一样被包装进Action,字段是ActionInput
//ArcherVolley里面引用的ClientAoeInpuf,就是个指示器
//实际上会生成一个GameObject在场景,挂脚本AoeActionInput控制
var actionPrototype = GameDataSource.Instance.GetActionPrototypeByID(m_ActionRequests[i].RequestedActionID);
if (actionPrototype.Config.ActionInput != null)
{
//如果ActionInpuf不为空,就生成指示器
var skillPlayer = Instantiate(actionPrototype.Config.ActionInput);
//指示器的参数传了委托Action,sendInput,实际就是发包
skillPlayer.Initiate(m_ServerCharacter, m_PhysicsWrapper.Transform.position, actionPrototype.ActionID, SendInput, FinishSkill);
m_CurrentSkillInput = skillPlayer;
}
else
{
PerformSkill(actionPrototype.ActionID, m_ActionRequests[i].TriggerStyle, m_ActionRequests[i].TargetId);
}
//鼠标操作
//IsPointerOverGameObject是unity很常用的方法,判断点击的是UI还是场景
if (!EventSystem.current.IsPointerOverGameObject() && m_CurrentSkillInput == null)
{
//IsPointerOverGameObject() is a simple way to determine if the mouse is overUI element. If it is, we don't perform mouse input logic,
//to model the button "blocking" mouse clicks from falling through ainteracting with the worl
//鼠标输入右键按下是攻击
if (Input.GetMouseButtonDown(1))
{
RequestAction(CharacterClass.Skill1.ActionID, SkillTriggerStyle.MouseClick);
}
//左键按下是选中目标,朝目标移动
if (Input.GetMouseButtonDown(0))
{
RequestAction(GameDataSource.Instance.GeneralTargetActionPrototype.ActionISkillTriggerStyle.MouseClick);
}
//如果当前帧不是按下左键,但左键还没松开,就认为是控制移动
else if (Input.GetMouseButton(0))
{
m_MoveRequest = true;
--todo:fixUpdate里面通过射线检测更新目标点
}
}
//用法
var actionPrototype = GameDataSource.Instance.GetActionPrototypeByID(m_ActionRequests[i].RequestedActionID);
if (actionPrototype.Config.ActionInput != null)
{
var skillPlayer = Instantiate(actionPrototype.Config.ActionInput);
skillPlayer.Initiate(m_ServerCharacter, m_PhysicsWrapper.Transform.position, actionPrototype.ActionID, SendInput, FinishSkill);
m_CurrentSkillInput = skillPlayer;
}
else
{
PerformSkill(actionPrototype.ActionID, m_ActionRequests[i].TriggerStyle, m_ActionRequests[i].TargetId);
}
//分主机和客机,serverRpc默认只有主机能调用
//如果声明了RequireOwnership=false,客机也能用,如角色选择
///
/// RPC to notify the server that a client has chosen a seat.
///
[ServerRpc(RequireOwnership = false)]
public void ChangeSeatServerRpc(ulong clientId, int seatIdx, bool lockedIn)
{
OnClientChangedSeat?.Invoke(clientId, seatIdx, lockedIn);
}
一知半解:网上看到的描述说这是种注入,实际使用效果更接近跨类共享数据
Inject注入介绍
声明后,可以在构造函数做一次赋值,之后就不能改变
//注册事件
///
/// Server notification when a client requests a different lobby-seat, or locks in theiseat choice
///
public event Action OnClientChangedSeat;
//响应事件
public void OnNetworkSpawn()
{
if (!NetworkManager.Singleton.IsServer)
{
enabled = false;
}
else
{
NetworkManager.Singleton.OnClientDisconnectCallback +OnClientDisconnectCallback;
networkCharSelection.OnClientChangedSeat += OnClientChangedSeat
NetworkManager.Singleton.SceneManager.OnSceneEvent += OnSceneEvent;
}
}
public void OnNetworkDespawn()
{
if (NetworkManager.Singleton)
{
NetworkManager.Singleton.OnClientDisconnectCallback -OnClientDisconnectCallback;
NetworkManager.Singleton.SceneManager.OnSceneEvent -= OnSceneEvent;
}
if (networkCharSelection)
{
networkCharSelection.OnClientChangedSeat -= OnClientChangedSeat;
}
}
//触发事件 这个函数声明了ServerRpc,并且函数名格式是xxxServerRpc
//也就是客户端调用,到服务器触发,然后服务器再通过事件到具体执行的函数上
///
/// RPC to notify the server that a client has chosen a seat.
///
[ServerRpc(RequireOwnership = false)]
public void ChangeSeatServerRpc(ulong clientId, int seatIdx, bool lockedIn)
{
OnClientChangedSeat?.Invoke(clientId, seatIdx, lockedIn);
}