Unity多人射击游戏开发:基于Photon Fusion 2的状态同步与网络对战实现

📅 发布时间:2026/7/26 15:30:15
Unity多人射击游戏开发:基于Photon Fusion 2的状态同步与网络对战实现 1. 项目概述为什么选择Photon Fusion 2来构建多人射击Demo如果你正在Unity里琢磨怎么做一个能和朋友联机对战的射击游戏大概率已经听过Photon这个名字。但Photon PUN、Photon Bolt、Photon Fusion这几个兄弟到底该选谁我当初也纠结过直到上手了Photon Fusion 2才明白它为什么被很多中小团队和独立开发者称为“多人游戏开发的瑞士军刀”。这个项目我们就用它来从零开始搭一个包含基础射击、实时血量同步和RPC调用的多人对战Demo。这不仅仅是“跑通一个例子”而是让你彻底搞懂Fusion 2这套框架的核心工作流和设计哲学。简单来说Photon Fusion 2是一个状态同步State Synchronization网络框架。它和PUN最大的区别在于PUN更像一个“消息快递员”帮你把每个客户端的行为比如“我开枪了”广播出去至于每个客户端收到消息后游戏状态会不会因为网络延迟而产生分歧比如你觉得打中了我觉得没打中PUN管得不多。而Fusion 2则是一个“权威的中央裁判”它有一个确定的、所有客户端都认可的游戏状态这个状态可以运行在某个客户端也可以运行在专用服务器上任何重要的逻辑比如伤害计算、胜负判定都在这个权威端执行然后只把结果同步给其他客户端。这就从根本上解决了“我明明打中了你你怎么没掉血”这类幽灵子弹问题。这个Demo的目标很明确创建一个简单的3D场景两个玩家可以加入互相射击。被击中者血量减少血量条实时更新当血量归零时玩家“死亡”并短暂等待后重生。整个过程会涉及Fusion 2最核心的几个概念Network Object网络对象、Network Transform网络变换、Networked Properties网络属性、Hitbox碰撞盒和RPC远程过程调用。通过亲手实现一遍你会对如何组织一个Fusion项目、如何处理玩家输入、如何同步状态、如何触发远程事件有一个肌肉记忆般的理解。无论你是想做个派对小游戏还是为更大的项目做技术预研这个流程都是必经之路。2. 环境准备与项目初始化搭建稳固的地基在开始写第一行游戏逻辑代码之前正确的环境配置和项目结构搭建至关重要。很多新手卡在第一步往往是因为某个插件版本不兼容或者SDK导入姿势不对。2.1 Unity版本与Photon Fusion 2 SDK导入首先确认你的Unity版本。Photon Fusion 2对Unity 2021 LTS和2022 LTS版本支持最好。我个人使用的是Unity 2022.3 LTS稳定性有保障。不建议使用最新的Alpha或Beta版本以免遇到未知的兼容性问题。接下来是获取Photon Fusion 2。你需要前往Photon Engine官网注册账号并创建一个新应用。在应用面板中选择“Fusion”作为网络解决方案。创建完成后你会获得一个重要的App Id这是你的项目连接Photon云服务的凭证稍后会用到。在Unity中最好的安装方式是通过Package Manager。打开Window - Package Manager点击左上角的“”号选择“Add package from git URL...”。输入Photon Fusion的Git仓库地址通常格式如com.photon.fusion具体地址需查看Photon官方文档。这种方式能确保你获取到的是官方维护的最新稳定版本并且便于后续更新。导入完成后Unity会重启一次。注意不要从Asset Store下载旧的Fusion 1版本两者的API和架构有显著差异混用会导致大量错误。导入后你会在菜单栏看到“Fusion”选项。首先我们需要配置网络设置。点击Fusion - Fusion Hub打开集线器窗口。在这里点击“Create New Fusion Project Config”它会自动生成一个FusionAppSettings资产。在这个资产的Inspector面板中粘贴你从官网获取的App Id到“App Id Fusion”字段。Region可以选择离你目标玩家群体最近的比如asia亚洲。2.2 核心场景与基础网络对象创建环境配置好后我们开始构建第一个场景。创建一个新的Unity场景命名为Gameplay。这个场景将是我们游戏的主战场。Fusion的核心是NetworkRunner。你可以把它理解为一个网络引擎负责管理连接、发送和接收数据、生成网络对象等所有底层网络事务。在场景中创建一个空物体命名为Network Runner然后为其添加NetworkRunner组件。同时我们还需要一个NetworkSceneManager组件在同一物体上添加即可它负责在玩家加入后自动为他们加载和同步当前场景。接下来我们需要一个“引导”场景。因为NetworkRunner需要在游戏一开始就建立连接所以通常我们会有一个简单的初始场景比如Launcher来启动它。创建Launcher场景同样创建一个带有NetworkRunner和NetworkSceneManager的空物体。但在这个场景里我们还需要一个简单的UI和一个脚本来自动启动连接。创建一个UI Canvas添加一个按钮比如“Start Host”和“Start Client”。然后创建一个C#脚本Launcher。这个脚本的核心是调用NetworkRunner的StartGame方法。using Fusion; using UnityEngine; using UnityEngine.UI; public class Launcher : MonoBehaviour { [SerializeField] private NetworkRunner _runnerPrefab; // 拖入预制体 private NetworkRunner _runner; public async void StartHost() { _runner Instantiate(_runnerPrefab); _runner.name Network Runner; var startGameArgs new StartGameArgs() { GameMode GameMode.Host, // 以主机模式启动 SessionName FusionDemoRoom, // 房间名 Scene 1, // 要加载的游戏场景索引Gameplay场景 SceneManager _runner.GetComponentNetworkSceneManager() }; await _runner.StartGame(startGameArgs); // 异步启动 } public async void StartClient() { _runner Instantiate(_runnerPrefab); _runner.name Network Runner; var startGameArgs new StartGameArgs() { GameMode GameMode.Client, // 以客户端模式启动 SessionName FusionDemoRoom, // 要加入的房间名 SceneManager _runner.GetComponentNetworkSceneManager() }; await _runner.StartGame(startGameArgs); // 加入主机创建的房间 } }将这个脚本挂载到Launcher场景的某个物体上并将UI按钮的点击事件绑定到对应的函数。同时将NetworkRunner的预制体从Gameplay场景中制作成预制体拖拽到脚本的_runnerPrefab字段。实操心得StartGame是一个异步方法使用await调用可以让代码更清晰。确保你的脚本继承了MonoBehaviour并且方法声明为async void对于UI事件处理器或async Task。在真实项目中你还需要在启动时显示加载界面并在StartGame完成后处理成功或失败的逻辑。3. 玩家角色网络化让角色在网络上“活”过来现在我们有了连接和场景。下一步是创建玩家角色并让它成为一个网络实体这样所有连接的客户端都能看到并控制它。3.1 创建Player预制体与NetworkObject组件在Gameplay场景中创建一个胶囊体Capsule作为玩家角色模型命名为Player。将其制作成预制体Prefab因为Fusion会在玩家加入时动态生成这个预制体。要让一个物体被Fusion网络管理必须为其添加NetworkObject组件。选中Player预制体在Inspector中点击“Add Component”搜索并添加NetworkObject。这个组件是Fusion识别和管理该物体的唯一标识。仅仅有NetworkObject还不够物体的移动需要同步。Fusion提供了NetworkTransform组件来高效地同步位置、旋转和缩放。为Player预制体添加NetworkTransform组件。保持其默认设置即可它会在网络更新时自动插值Interpolate让其他客户端看到的移动更加平滑。3.2 实现基础移动与输入处理在Fusion中处理玩家输入有特定的方式。我们需要创建一个脚本继承自NetworkBehaviour而不是MonoBehaviour并实现特定的接口。创建一个C#脚本PlayerController将其挂载到Player预制体上。using Fusion; using UnityEngine; public class PlayerController : NetworkBehaviour { [SerializeField] private float _moveSpeed 5f; [SerializeField] private float _jumpForce 5f; [SerializeField] private LayerMask _groundLayer; [SerializeField] private Transform _groundCheck; [SerializeField] private float _groundCheckRadius 0.2f; private CharacterController _characterController; private Vector3 _velocity; private bool _isGrounded; public override void Spawned() { // 当这个网络对象在客户端生成时调用 if (Object.HasInputAuthority) // 判断这个客户端是否有输入控制权即这是“我”控制的角色 { // 可以在这里为本地玩家做一些特殊设置比如摄像机跟随 Camera.main.transform.SetParent(transform); Camera.main.transform.localPosition new Vector3(0, 1, -3); Camera.main.transform.localRotation Quaternion.identity; } _characterController GetComponentCharacterController(); } public override void FixedUpdateNetwork() { // 这是一个与网络Tick率同步的固定更新循环 if (GetInput(out NetworkInputData input)) { // 处理移动输入 Vector3 moveDirection transform.forward * input.MoveInput.y transform.right * input.MoveInput.x; _characterController.Move(moveDirection * _moveSpeed * Runner.DeltaTime); // 处理跳跃 _isGrounded Physics.CheckSphere(_groundCheck.position, _groundCheckRadius, _groundLayer); if (input.IsJumpPressed _isGrounded) { _velocity.y Mathf.Sqrt(_jumpForce * -2f * Physics.gravity.y); } // 应用重力 _velocity.y Physics.gravity.y * Runner.DeltaTime; _characterController.Move(_velocity * Runner.DeltaTime); } } }这里出现了一个新东西NetworkInputData。我们需要定义一个结构体来描述玩家的输入。在项目中创建一个新的C#脚本文件NetworkInputData.cs注意这不是一个组件只是一个数据结构。using Fusion; public struct NetworkInputData : INetworkInput { public Vector2 MoveInput; // WASD或摇杆输入 public NetworkBool IsJumpPressed; // 跳跃键 public NetworkBool IsFirePressed; // 开火键 public Vector3 AimForwardVector; // 瞄准方向用于射击 }现在我们需要一个地方来收集本地玩家的输入并将其传递给NetworkRunner。这就是NetworkEvents和输入回调的用武之地。通常我们会创建一个全局的输入收集器。但更常见的做法是在PlayerController脚本中通过实现INetworkRunnerCallbacks接口或使用NetworkRunner.AddCallbacks来注册输入回调。为了简化我们可以在PlayerController的Spawned方法中只让有输入权限的实例来收集输入。实际上Fusion推荐的方式是使用NetworkRunner的GetInput方法它会自动为你收集并处理输入。我们上面代码中的GetInput(out NetworkInputData input)就是做这个的。但是我们需要告诉Fusion如何获取输入。这通常通过实现INetworkRunnerCallbacks接口中的OnInput方法来完成。创建一个新的脚本InputHandler它不挂载在任何物体上而是作为一个回调处理器。using Fusion; using UnityEngine; public class InputHandler : MonoBehaviour, INetworkRunnerCallbacks { public void OnInput(NetworkRunner runner, NetworkInput input) { var inputData new NetworkInputData(); // 收集键盘输入 inputData.MoveInput.x Input.GetAxisRaw(Horizontal); inputData.MoveInput.y Input.GetAxisRaw(Vertical); inputData.IsJumpPressed Input.GetButton(Jump); inputData.IsFirePressed Input.GetMouseButton(0); // 鼠标左键开火 // 收集鼠标瞄准方向简单示例假设角色朝摄像机前方看 Ray mouseRay Camera.main.ScreenPointToRay(Input.mousePosition); Plane groundPlane new Plane(Vector3.up, Vector3.zero); if (groundPlane.Raycast(mouseRay, out float distance)) { Vector3 worldPoint mouseRay.GetPoint(distance); inputData.AimForwardVector (worldPoint - runner.GetPlayerObject(runner.LocalPlayer).transform.position).normalized; } input.Set(inputData); // 将输入数据设置到网络输入中 } // 以下接口方法必须实现但可以留空 public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) { } public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) { } public void OnInputMissing(NetworkRunner runner, PlayerRef player, NetworkInput input) { } public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) { } public void OnConnectedToServer(NetworkRunner runner) { } public void OnDisconnectedFromServer(NetworkRunner runner) { } public void OnConnectRequest(NetworkRunner runner, NetworkRunnerCallbackArgs.ConnectRequest request, byte[] token) { } public void OnConnectFailed(NetworkRunner runner, NetAddress remoteAddress, NetConnectFailedReason reason) { } public void OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) { } public void OnSessionListUpdated(NetworkRunner runner, ListSessionInfo sessionList) { } public void OnCustomAuthenticationResponse(NetworkRunner runner, Dictionarystring, object data) { } public void OnHostMigration(NetworkRunner runner, HostMigrationToken hostMigrationToken) { } public void OnReliableDataReceived(NetworkRunner runner, PlayerRef player, ArraySegmentbyte data) { } public void OnSceneLoadDone(NetworkRunner runner) { } public void OnSceneLoadStart(NetworkRunner runner) { } }然后在Launcher脚本中在实例化NetworkRunner后需要将这个InputHandler添加为回调。// 在StartHost或StartClient方法中实例化_runner后 _runner.AddCallbacks(FindObjectOfTypeInputHandler()); // 假设InputHandler挂载在场景中某个物体上至此一个基础的可移动玩家角色就完成了。当你在主机启动游戏并用客户端加入后应该能看到两个胶囊体并且可以分别控制它们移动。注意事项FixedUpdateNetwork的执行频率由NetworkRunner的TickRate决定默认是每秒60次。所有与物理和游戏状态确定性相关的逻辑都应该放在这里而不是MonoBehaviour的Update中。Update可以用来处理本地表现比如UI更新、非确定性的视觉效果。4. 实现射击与伤害判定网络游戏的核心冲突射击是FPS游戏的灵魂而在网络上实现它难点在于公平性和即时性。我们需要解决几个问题谁有权限判定击中如何检测击中伤害如何计算和同步4.1 射线检测与Hitbox系统在单机游戏中我们通常用Physics.Raycast从摄像机或枪口发射一条射线来检测击中。但在Fusion中如果每个客户端都自己做射线检测会因为网络延迟和轻微的运行差异导致“我打中了你但你的客户端不承认”的情况。Fusion的解决方案是服务器权威Server Authoritative的命中检测。具体实现有两种主流方式使用Fusion内置的Hitbox系统这是最精确、最推荐的方式。你需要为玩家模型设置Hitbox碰撞盒然后在有输入权限的客户端或主机发起射线检测但检测的计算请求会发送到服务器或主机上执行服务器使用所有客户端的Hitbox状态进行权威计算再将结果同步。使用Lag Compensation延迟补偿的RaycastFusion提供了Runner.LagCompensation.Raycast方法它可以在进行射线检测时将时间回滚到过去某个时刻考虑指令延迟在那个时刻的世界状态下进行检测从而提高在延迟下的命中公平性。对于我们的Demo为了展示完整流程我们采用Hitbox系统。首先为Player预制体添加HitboxRoot组件。然后在玩家模型比如胶囊体的子物体上添加Hitbox组件。通常一个角色会有多个Hitbox比如头部、身体、四肢每个Hitbox可以设置不同的伤害倍数。为了简化我们只添加一个代表身体的Hitbox。将Hitbox组件的Hitbox Type设置为Box并调整其大小和位置包裹住胶囊体。4.2 编写射击逻辑与伤害计算现在我们在PlayerController脚本中增加射击功能。我们需要一个Networked Property来同步玩家的血量以及一个处理射击和伤害的方法。首先定义血量属性。在PlayerController类内部添加[Networked] public float Health { get; set; } // 这是一个网络同步的属性[Networked]属性是Fusion的魔法。标记了它的属性其值的变化会被自动同步到所有客户端。注意只有Host或Server可以修改[Networked]属性的值这是保证状态一致性的关键。然后在FixedUpdateNetwork中处理开火输入public override void FixedUpdateNetwork() { if (GetInput(out NetworkInputData input)) { // ... 原有的移动代码 ... // 处理射击 if (input.IsFirePressed) { Fire(input.AimForwardVector); } } } private void Fire(Vector3 aimDirection) { // 只有有输入权限的客户端或主机才能发起射击请求 if (!Object.HasInputAuthority !Runner.IsServer) return; // 从枪口位置发射射线假设枪口是玩家前方的一个点 Vector3 firePosition transform.position Vector3.up * 1f; // 枪口高度 if (Runner.LagCompensation.Raycast(firePosition, aimDirection, 100f, Object.InputAuthority, out var hit, -1, HitOptions.IncludePhysX)) { // 如果击中了带有Hitbox的物体 if (hit.Hitbox ! null) { var hitPlayer hit.Hitbox.Root.GetComponentPlayerController(); if (hitPlayer ! null hitPlayer ! this) // 确保不是打到自己 { // 调用RPC在服务器上执行伤害计算 RPC_TakeDamage(10f, Object.InputAuthority); } } // 也可以处理击中普通碰撞体的情况比如环境 else if (hit.Collider ! null) { // 生成击中特效本地表现 SpawnHitEffect(hit.Point); } } } [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)] private void RPC_TakeDamage(float damageAmount, PlayerRef damageSource) { // 这个RPC会从调用者有输入权限的客户端发送到状态权威端主机/服务器 // 只有在这里我们才能安全地修改[Networked]属性Health Health - damageAmount; Debug.Log(${Object.Name} took {damageAmount} damage from {damageSource}. Health: {Health}); if (Health 0) { RPC_OnDeath(); } } [Rpc(RpcSources.StateAuthority, RpcTargets.All)] private void RPC_OnDeath() { // 这个RPC由状态权威端发起广播给所有客户端 Debug.Log(${Object.Name} died!); // 这里可以触发死亡动画、禁用控制器等 if (Object.HasStateAuthority) // 只有状态权威端能处理重生逻辑 { // 延迟一段时间后重生 StartCoroutine(RespawnCoroutine()); // 注意协程需要在MonoBehaviour中运行 } } private System.Collections.IEnumerator RespawnCoroutine() { // 禁用角色控制器和渲染器 GetComponentCharacterController().enabled false; GetComponentInChildrenMeshRenderer().enabled false; yield return new WaitForSeconds(3f); // 等待3秒 // 重置位置和状态 transform.position GetRandomSpawnPoint(); Health 100f; GetComponentCharacterController().enabled true; GetComponentInChildrenMeshRenderer().enabled true; }这里有几个关键点RPC_TakeDamage方法它被标记为[Rpc]。RPCRemote Procedure Call用于在网络上调用一个特定的函数。RpcSources.InputAuthority表示只有拥有输入权限的客户端即开枪者可以调用这个RPC。RpcTargets.StateAuthority表示这个RPC的目标是状态权威端主机/服务器。这意味着伤害计算的实际执行权在服务器客户端只是“申请”造成伤害。RPC_OnDeath方法它由状态权威端调用并广播给所有客户端RpcTargets.All。这样所有玩家都能同时知道某个玩家死亡了可以播放统一的死亡效果。Runner.LagCompensation.Raycast我们使用了带延迟补偿的射线检测。Object.InputAuthority参数传入了开枪者的PlayerRef这有助于延迟补偿系统进行更精确的回滚计算。HitOptions.IncludePhysX表示射线也会检测普通的Unity物理碰撞体。状态修改权限注意Health属性的修改只发生在RPC_TakeDamage中而这个RPC只在状态权威端执行。客户端不能直接修改Health这防止了作弊。4.3 同步血量UI与视觉反馈血量变化需要实时反馈给玩家。我们需要一个UI血条。在Player预制体上创建一个UI Canvas设置为World Space添加一个Slider作为血条。然后在PlayerController脚本中我们需要在Update用于本地表现中更新这个血条。using UnityEngine.UI; // 需要引入UI命名空间 public class PlayerController : NetworkBehaviour { // ... 之前的变量 ... [SerializeField] private Slider _healthSlider; // 拖入血条Slider public override void Spawned() { // ... 之前的Spawned代码 ... if (Object.HasInputAuthority) { // ... 摄像机设置 ... } // 初始化血条 _healthSlider.maxValue 100f; _healthSlider.value Health; } public override void Render() // Fusion特有的渲染更新循环 { // Render在每次网络状态插值后调用适合更新视觉表现 // 更新血条值。注意Health是网络属性这里读取的是插值后的平滑值。 _healthSlider.value Health; // 也可以根据血量改变血条颜色 _healthSlider.fillRect.GetComponentImage().color Color.Lerp(Color.red, Color.green, Health / 100f); } }Render方法是Fusion中用于处理视觉表现和插值的地方。它会在FixedUpdateNetwork逻辑更新之后执行使用插值后的网络数据来更新画面使得运动和其他同步值看起来非常平滑即使网络Tick率不高。实操心得FixedUpdateNetwork和Render的分离是Fusion的一大优势。逻辑位置计算、伤害判定在FixedUpdateNetwork中以固定的网络tick率运行保证了确定性。而画面渲染在Render中根据插值后的数据更新保证了流畅性。永远不要在Render中修改网络状态[Networked]属性也尽量避免在FixedUpdateNetwork中直接操作视觉组件如Transform、Animator除非你知道自己在做什么。5. 游戏流程管理与状态同步让对战有始有终一个完整的Demo还需要游戏规则比如玩家重生、游戏回合、胜负判定等。这些通常属于“游戏状态”Game State的管理范畴。5.1 创建GameManager管理游戏逻辑我们需要一个全局的、网络化的管理器来协调游戏流程。创建一个空物体命名为GameManager为其添加NetworkObject组件并创建一个脚本GameManager。using Fusion; using System.Collections.Generic; using UnityEngine; public class GameManager : NetworkBehaviour { public static GameManager Instance { get; private set; } [Networked] private TickTimer _respawnTimer { get; set; } // 网络化的计时器 [Networked] public NetworkBool IsGameOver { get; set; } private DictionaryPlayerRef, PlayerController _playerList new DictionaryPlayerRef, PlayerController(); private void Awake() { if (Instance null) Instance this; else Destroy(gameObject); } public override void Spawned() { // 确保GameManager只在主机上存在一份 if (Runner.IsServer) { // 初始化游戏状态 IsGameOver false; } } public void RegisterPlayer(PlayerRef playerRef, PlayerController player) { if (Runner.IsServer) { _playerList[playerRef] player; Debug.Log($Player {playerRef} registered.); } } public void UnregisterPlayer(PlayerRef playerRef) { if (Runner.IsServer _playerList.ContainsKey(playerRef)) { _playerList.Remove(playerRef); } } public void RequestRespawn(PlayerController player) { if (Runner.IsServer) { // 设置一个3秒后的网络计时器 _respawnTimer TickTimer.CreateFromSeconds(Runner, 3f); // 可以在这里存储需要重生的玩家信息 } } public override void FixedUpdateNetwork() { if (Runner.IsServer) { // 检查重生计时器 if (_respawnTimer.Expired(Runner)) { // 执行重生逻辑比如将玩家传送到随机出生点 // _respawnTimer TickTimer.None; // 重置计时器 } // 检查游戏结束条件例如只剩一名玩家存活 // if (_playerList.Values.Count(p p.Health 0) 1) { ... } } } }然后在PlayerController的Spawned和OnDeath中需要与GameManager交互。// 在PlayerController的Spawned方法中 public override void Spawned() { // ... 原有代码 ... if (Runner.IsServer) // 或者 Object.HasStateAuthority { GameManager.Instance?.RegisterPlayer(Object.InputAuthority, this); } } // 在RPC_OnDeath方法中调用GameManager的重生请求 [Rpc(RpcSources.StateAuthority, RpcTargets.All)] private void RPC_OnDeath() { Debug.Log(${Object.Name} died!); if (Object.HasStateAuthority) { GameManager.Instance?.RequestRespawn(this); StartCoroutine(RespawnCoroutine()); } }5.2 玩家生成点与随机分配我们需要在场景中放置一些空物体作为玩家出生点Spawn Point。在GameManager中维护一个出生点列表并在玩家加入或重生时随机分配一个。public class GameManager : NetworkBehaviour { [SerializeField] private Transform[] _spawnPoints; // 在Inspector中拖入出生点Transform数组 public Transform GetRandomSpawnPoint() { if (_spawnPoints null || _spawnPoints.Length 0) return transform; // 回退到GameManager自身位置 return _spawnPoints[Random.Range(0, _spawnPoints.Length)]; } // 在玩家加入时调用 public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) { if (runner.IsServer) { // 为玩家生成角色并指定出生点 Vector3 spawnPos GetRandomSpawnPoint().position; runner.Spawn(playerPrefab, spawnPos, Quaternion.identity, player); } } }为了让GameManager能接收到玩家加入的事件我们需要让它实现INetworkRunnerCallbacks接口或者将之前InputHandler中的回调移到GameManager中。更清晰的做法是创建一个专门的NetworkCallbacks脚本来集中处理所有网络回调。6. 性能优化与调试技巧让Demo跑得更稳当基础功能都实现后我们还需要关注性能和调试确保Demo在真机联机时也能顺畅运行。6.1 网络带宽优化策略Fusion虽然高效但不合理的同步也会浪费带宽。以下是一些优化点Networked Properties的精度对于位置、旋转NetworkTransform已经做了优化。对于自定义的[Networked]属性思考它是否需要高精度。比如血量如果最大值是100用int比用float更省带宽。Fusion提供了[Networked, Accuracy(0.1)]这样的属性来指定浮点数的同步精度如果变化小于0.1则不同步。RPC的使用频率避免在每一帧都调用RPC。例如射击RPC应该在按下开火键时调用一次而不是在按住时持续调用。可以使用一个布尔值来记录上一帧是否开火只在状态变化时触发。Interest Management兴趣管理对于大型地图和大量玩家的游戏Fusion支持兴趣管理即只同步玩家附近的对象状态。我们的Demo规模小可以暂时不用但需要知道这个概念。禁用不必要的组件同步确保只有需要同步的物体才有NetworkObject。对于静态环境不需要网络组件。6.2 Fusion Debug工具与常见问题排查Photon Fusion提供了强大的可视化调试工具在开发阶段务必善用。在Unity编辑器中运行游戏后你可以打开Fusion - Fusion Statistics窗口。这个窗口会实时显示Network Stats发送/接收的字节数、数据包数量、网络对象数量。这是观察带宽消耗最直接的地方。Objects当前所有网络对象的列表可以查看它们的ID、状态权威等信息。Input输入延迟和缓冲情况。MiscRPC调用次数等。另一个有用的工具是Fusion - Network Project Config中的Simulation选项卡。在这里你可以模拟网络条件比如增加延迟Latency、丢包Packet Loss和抖动Jitter。在本地测试时开启这些模拟可以提前发现高延迟下的游戏体验问题比如移动卡顿、射击反馈延迟等。常见问题排查清单玩家移动不流畅有抖动检查NetworkTransform组件的插值模式。对于玩家控制的物体通常使用Interpolate。确保移动逻辑写在FixedUpdateNetwork中而不是Update。在NetworkRunner组件上尝试调整Interpolation Mode和Interpolation Delta。RPC调用没反应首先检查RPC方法的RpcSources和RpcTargets是否正确。一个常见的错误是试图从客户端调用一个目标为RpcTargets.StateAuthority的RPC但调用者自身不是输入权威Object.HasInputAuthority为false。在RPC方法内添加Debug.Log并查看Console输出确认RPC是否被调用和执行。检查NetworkRunner是否正常运行玩家连接是否成功。[Networked]属性不同步记住只有Host或Server状态权威端对[Networked]属性的修改才会被同步。客户端修改是无效的。确保属性有正确的get; set;访问器。对于复杂类型如结构体、数组需要实现INetworkStruct接口或使用NetworkArray。Hitbox检测不到确认Hitbox和HitboxRoot组件已正确添加并且Hitbox的Hitbox Type和尺寸设置正确。确保在射线检测时HitOptions参数中包含了HitOptions.IncludeHitboxes在我们使用LagCompensation.Raycast时默认是包含的。使用Fusion的Hitbox Debug可视化工具在Scene视图右上角的Gizmos下拉菜单中勾选来查看Hitbox的实际位置和大小。踩过几次坑之后我最大的体会是理解Fusion的权限模型是解决问题的关键。时刻问自己当前这段代码在哪个端运行Host/Server, Client with Input Authority, Client without Authority它是否有权限修改这个网络状态它发起的这个动作如射线检测、RPC调用的目标端是谁把这些问题想清楚大部分同步问题都能迎刃而解。最后这个Demo只是一个起点。基于这个框架你可以轻松地扩展更多功能比如不同的武器系统通过NetworkBehaviour管理武器状态、技能系统使用RPC触发、更复杂的玩家状态如装备、buff、房间匹配系统使用Fusion的Session和Lobby功能等等。Fusion 2提供了一套相对完整且高效的底层让你能把更多精力花在游戏玩法本身而不是反复纠结于网络同步的坑。