☰
基于Unity与PurrNet的FPS联机开发实战:从选型到射击判定
2026/10/17 9:10:32 网站建设 项目流程

做FPS联机这件事,我踩过的坑比大多数教程里写的多得多。尤其是当你想用Unity做一款类似CS、CF、PUBG那种多人对战射击游戏时,最核心的问题从来不是“怎么把枪建模得好看”,而是“两个人同时开枪,服务器到底信谁的”。我之前用Mirror和Netcode都折腾过一阵子,后来转到了PurrNet,才算是把“联机对战”这条链路彻底理顺了。这篇文章就是我基于Unity和PurrNet做多人FPS的完整实战记录,从选型理由、角色同步、射击判定,到房间管理和重生循环,全部拆开讲清楚,顺便把那些文档里不会写的坑也一并交代了。

先说结论:如果你想要一个上手快、没有大量代码生成、同时又能支撑起FPS这种高频同步需求的网络库,PurrNet确实是一个值得认真考虑的选择。它的API设计贴近直觉,不像别的方案那样逼着你理解一大堆底层管线。但纯粹靠库是不够的,FPS联机真正难的是“游戏逻辑应该放在哪一端”,以及“不同步时该怎么让玩家体感依然流畅”。这些才是决定项目能不能顺利跑起来的关键。

我默认看这篇文章的朋友已经能用Unity做出单机FPS的基础原型,比如角色移动、相机控制、开枪射线检测这些都没问题。如果你的单机部分还没搞定,建议先把那些基础功能做扎实,再接网络层,不然两边同时出问题时,排查起来会非常痛苦。

1. 为什么是PurrNet:一个不会被大量代码生成拖垮的联机方案

先说选型。市面上Unity联机方案其实不少,Mirror是很多人的默认选择,Netcode for GameObjects也有官方背书,但PurrNet的定位很有意思:它想让你“像写单机代码一样写联机代码”。这一点在我实际使用后体会特别深。

1.1 Mirror、Netcode、PurrNet三者在FPS场景下的实际差异

Mirror的优点是成熟、资料多,社区里各种FPS模板都能找到。但它的同步机制需要你在写NetworkBehaviour时处处考虑“这个变量要不要同步”,而且它那种基于属性的同步方式,在高频更新的FPS里容易出现网络消息爆炸的问题。Netcode for GameObjects背靠官方,功能很全,但配套的配置项比较多,RPC、NetworkVariable、SceneManagement这些模块学起来有一定曲线,对新手来说不算友好。PurrNet给我的感觉是它把“传输层”和“游戏层”分得比较清楚,同时支持Server权威、客户端预测这些FPS必须的能力,又没有绑定一大堆你未必用得到的组件。

对比直接列出来,大家看得更明白。

维度MirrorNetcode for GameObjectsPurrNet
代码生成依赖部分版本依赖生成器依赖代码生成和人造管道几乎零代码生成
RPC调用方式Command / Rpc特性ServerRpc / ClientRpcServerRpc / ObserversRpc / TargetRpc
数据同步SyncVar / SyncListNetworkVariableNetworkField / NetworkVar
上手曲线中等偏高较低
熟悉度大量旧项目在用官方长期维护社区相对新,但更新活跃
FPS高频同步需要手工优化同步频率支持,但配置繁琐原生支持网络Transform插值和可变同步频率

不是说Mirror和Netcode不行,而是对“快速做出一个能玩的多人FPS”这个目标来说,PurrNet能省掉不少心智负担。我在它的基础上写角色同步和射击逻辑时,思路基本没有被网络细节打断。

1.2 PurrNet的核心工作方式

PurrNet最核心的概念,是让每个挂载了网络组件的对象都有一个自己的身份,然后通过RPC和网络字段来通信。你在代码里可以写[ServerRpc]表示“这个函数在服务器上执行”,写[ObserversRpc]表示“所有正在观察这个对象的客户端都会执行”。这种声明式的写法,比起手动维护消息类型和回调注册要直白得多。

它的优点具体到FPS项目上,我的体会是:你不需要为每一次按键、每一次伤害单独写一套消息协议。扣扳机时客户端调用一个ServerRpc,服务器计算判定,再把结果通过ObserversRpc广播给其他人,整条链路非常统一。同步位置的逻辑也被封装成了现成组件,我在制作初期基本没碰传输层细节,先跑通玩法,后面再针对性能做调整,这个节奏对独立开发者特别友好。

提示:任何网络库都不是魔法。PurrNet同样要求你理解“哪些逻辑必须在服务器执行”,这一点会在后面的射击链路里重点说。

2. 玩家预制体的骨架:角色控制、相机与网络身份如何各司其职

联机FPS和单机FPS最大的不同,在于同一个玩家对象被分成了“本地表现”和“网络身份”两层。本地表现包含相机、枪械模型、准星这些只有自己能看到的东西;网络身份则是服务器和其他玩家眼里的你。这两个角色如果混在一起写,项目很快就会变成一团乱麻。

2.1 玩家预制体需要哪些组件

我先说最终版的结构,再说为什么这样分。一个PurrNet联网玩家预制体,我通常会按下面这张表来组织:

组件/脚本职责属于本地还是所有人
NetworkObject赋予网络身份,是同步的基本单位所有人
PurrNetBehaviour脚本网络事件的入口,比如自己是否已被服务器生成所有人
CharacterController / Rigidbody处理物理运动所有人,但只在本地端驱动
Transform同步组件把位置旋转同步给其他客户端所有人
相机渲染视角仅本地
枪械模型/瞄准系统视觉反馈仅本地
伤害接收脚本被子弹击中后的处理入口所有人

有几个细节值得注意。第一,相机绝对不能同步给其他玩家,否则对方的屏幕上会跟着你的视角疯狂抖动。第二,位置同步不要挂在物理刚体上直接同步所有物理属性,而是同步Transform或专用同步组件,否则网络延迟会直接变成物理抖动。第三,角色的移动逻辑虽然只在本地执行,但服务器必须保留一个“校验位置”的能力,目的是防止有人用修改器瞬移。

2.2 本地控制与网络身份的桥接

在PurrNet中,服务器会为每个连接生成玩家对象,通常是在一个OnPlayerJoined回调里调用生成逻辑。生成之后,客户端会收到一个“这个对象属于我”的事件。我用一段简化的代码来说明这个桥接逻辑:

public class PlayerSpawner : PurrNetBehaviour { [ServerRpc] private void RequestSpawnPlayer() { var go = Instantiate(playerPrefab); NetworkManager.Spawn(go); // 给这个玩家分配初始位置 } }

这段做的是服务端生成,真正让“属于我”这个感知落到客户端,还需要在玩家脚本里做判断。我的做法是:

public class PlayerController : PurrNetBehaviour { public bool IsOwner { get; private set; } protected override void OnOwnerChanged(bool isOwner) { IsOwner = isOwner; if (isOwner) { // 启用本地摄像机、输入组件 GetComponentInChildren<Camera>().gameObject.SetActive(true); GetComponent<PlayerInput>().enabled = true; } } }

这样设计的好处是:同一个预制体,在别人眼里是一具只有动作和名字的模型,在自己眼里才是可以操控的角色。整条玩家生成链路里,最需要记住的一句话是:Server负责身份的诞生,Client负责身份的体验。

注意:不同PurrNet版本的API名称可能有差异,比如获取Owner的方式也许从事件方法变成属性判断,但“以Owner为中心区分本地和非本地逻辑”的思路完全一致。遇到编译报错时,优先查你安装版本的接口变更说明。

3. 射击链路:从扣扳机到扣血,判定权到底放在哪一端

FPS联机最核心的技术分水岭,就是射击判定到底放在客户端还是服务器。放进客户端,自己开枪几乎不会有延迟感,但别人看到你时,位置、血量全都不准,作弊更是拦不住。放进服务器,公平性有了保障,但网络一旦波动,玩家会觉得“明明打中了却没伤害”。怎么取舍和实现,是整个联机项目的重中之重。

3.1 为什么判定权必须交给服务器

我用一个具体场景来说明。A玩家和B玩家同时发现对方,A的网络延迟是30ms,B是120ms。如果判定放在客户端,A在自己的屏幕上打中B,A会把“命中”结果发给服务器,服务器可能直接采信,于是B明明在B自己的屏幕上已经躲进掩体了,却还是死了。这种体验在FPS里是致命的,玩家会立刻骂“这是什么垃圾网络”。

所以标准方案是:客户端开枪时只发送“开枪的起点和方向”给服务器,服务器拿着这个射线在自己的世界坐标里进行碰撞检测。服务器看到B已经躲进掩体,就让这颗子弹空掉。这样一来,A的高延迟只会影响A自己能不能打中,而不会让B处于“被延迟击杀”的不公平状态。

3.2 从扣扳机到扣血的完整调用链

我把射击链路拆成四个环节,每个环节都有明确的归属端。

第一环节,本地客户端产生射击意图。这个时候玩家按下左键,本地播放枪口火光、枪声、弹痕,这些属于纯视觉内容,不进网络。同时客户端调用一个ServerRpc,把自己看到的武器发射原点、瞄准方向发给服务器。

public class Weapon : PurrNetBehaviour { private void FireInput() { // 本地播放开火特效 PlayMuzzleFlash(); PlayGunshotSound(); var origin = Camera.main.transform.position; var direction = Camera.main.transform.forward; RequestPerformShot(origin, direction); } [ServerRpc] private void RequestPerformShot(Vector3 origin, Vector3 direction) { PerformServerHitDetection(origin, direction); } }

第二环节,服务器执行射线检测。这个过程必须使用服务器的物理场景。注意一个细节:如果角色模型和碰撞体更新有延迟,服务器射线就可能“穿过”一个实际不存在的旧位置,所以服务器射线检测的起点和方向都应该附带一个时间戳,或者用延迟补偿算法来做修正。对于初版项目,简单取“服务器当前状态下的射线检测”即可,等体感问题出现了,再考虑更复杂的回退机制。

private void PerformServerHitDetection(Vector3 origin, Vector3 direction) { if (Physics.Raycast(origin, direction, out RaycastHit hit, weaponRange)) { if (hit.collider.TryGetComponent(out IDamageable target)) { target.TakeDamage(weaponDamage, OwnerId); } } }

第三环节,伤害处理。所有凡是继承IDamageable的对象,比如玩家、可破坏掩体,都实现TakeDamage。在这个函数里,扣血、判断死亡、更新击杀者分数,全部只能由服务器视角来执行。血量数值我用网络字段同步给所有客户端,这样每个人的血条都能实时看见。

public class PlayerHealth : PurrNetBehaviour, IDamageable { [NetworkField] private int currentHealth = 100; public void TakeDamage(int amount, int attackerId) { if (!IsServer) return; currentHealth -= amount; if (currentHealth <= 0) { Die(attackerId); } } private void Die(int killerId) { // 记录击杀者分数,广播死亡事件,启动重生流程 DeathEvents?.Invoke(killerId); RespawnManager.RequestRespawn(this); } }

第四环节,死亡反馈。服务器广播死亡事件后,每个客户端播放本地死亡动画、击杀提示、击杀者名字等。这里我用了ObserversRpc,意思是“所有能看到这场战斗的玩家都执行”,而不仅仅是房主或某个特定客户端。

[ObserversRpc] private void BroadcastDeath(int victimId, int killerId) { UIManager.Instance.ShowKillFeed(victimId, killerId); }

这条链路的本质,是用“最小信任模型”来保证公平:客户端被允许做的只有两件事,一是表达意图(开枪、移动),二是呈现结果(播放动画、更新界面),一切关系到对错的判定都在服务器完成。对这个链路理解得越透彻,后续你的网络代码就越少。

提示:很多新手会把“客户端请求”和“客户端决定”混为一谈。记住一个原则:客户端可以请求开枪,但能不能打中、扣多少血、死没死,必须由服务器说了算。

4. 对战循环:房间、出生点、计分板和重生的状态机设计

射击链路打通之后,你的项目还只是一个“两个小人可以互相打掉血”的Demo。要把它变成“能玩一局”的FPS游戏,你需要把对战流程串起来:玩家进入房间、选择出生点、开始战斗、死亡等待、重新部署、比赛结束。这一段我把它统称为对战循环,是很多自学者最容易卡住的地方。

4.1 服务器权威的游戏状态机

一局FPS游戏天然就是一个状态机:大厅(Lobby)→ 游戏中(Playing)→ 结算(GameOver)。我建议把这个状态机的控制权完全放在服务器端,客户端只做展示。为什么?因为如果客户端各自维护状态,延迟不同的玩家会看到完全不同的比赛进度,A玩家已经进入战斗了,B玩家还在加载画面。

实际实现上,我会用一个简单的枚举状态字段加事件广播来驱动:

public enum GameState { Lobby, Playing, GameOver } public class GameFlowController : PurrNetBehaviour { [NetworkField] private GameState currentState = GameState.Lobby; public void StartGame() { if (!IsServer) return; currentState = GameState.Playing; BroadcastStateChange(currentState); } }

服务器在合适的时机推进状态,比如房间人数达到4人后倒计时3秒自动开始,某一方达到指定击杀数后进入结算。客户端收到状态变更事件后,切换UI界面、解锁玩家控制、显示比分面板。

4.2 玩家列表与房间管理的轻量做法

很多教程会教你怎么搭大厅、怎么做房间列表、怎么搜索房间。但从实际出发,如果你的目标是一款朋友之间开黑的FPS,直接用“服务器创建房间 → 客户端输入IP加入”的方式就够了,不需要引入复杂的匹配服务。

PurrNet里管理玩家列表的思路是这样的:服务器维护一份“所有已连接玩家”的集合,并在有人加入或离开时广播给房间内的所有人。

public class RoomManager : PurrNetBehaviour { private readonly List<PlayerInfo> _players = new(); protected override void OnPlayerJoined(Player player) { if (!IsServer) return; var info = new PlayerInfo(player.id, player.name); _players.Add(info); BroadcastPlayerList(_players); } [ObserversRpc] private void BroadcastPlayerList(List<PlayerInfo> players) { UIManager.Instance.RefreshPlayerList(players); } }

需要提醒的是,RPC的参数类型尽量保持简单。如果你传输的是自定义对象,必须确保该类型可以被序列化,并且尽量放进独立的“数据类”中,不要直接传Unity引擎类。不同版本对List、Dictionary的支持程度也不一样,初版项目能传数组就传数组,少给自己找麻烦。

4.3 出生点的选取策略与防“循环击杀”

对战流程里一个体验差距很大的细节是出生点。如果你让所有玩家死后都在同一个点重生,就会发生一种很滑稽的情况:一个人堵在出生点,另一人刚复活就被打死,死了又复活,又被同一个人打死。这在玩家眼里属于很严重的体验事故。

我的处理方式是给每个玩家阵营分配多个出生点,重生时从“当前没有被敌方玩家视野覆盖”的点里随机选一个。具体判断可以用简单的距离检测:如果某个出生点周围有敌方玩家,就换个点。判定逻辑和重生时序都由服务器控制。

public class SpawnManager : PurrNetBehaviour { public Transform GetSafeSpawnPoint(Team team) { var candidates = team == Team.Attacker ? attackerSpawns : defenderSpawns; foreach (var spawn in candidates) { if (!IsNearEnemy(spawn.position, team)) { return spawn; } } return candidates[Random.Range(0, candidates.Length)]; } private bool IsNearEnemy(Vector3 position, Team team) { // 用OverlapSphere检测附近是否有敌方玩家 } }

重生流程还有一个容易被忽略的细节:延迟重生。死掉之后立刻重生,玩家会有明显的“怎么又活了”的错乱感,而且服务器性能会被瞬间出现的多个对象拖累。正确的做法是服务器启动一个延迟任务,比如3到5秒后在安全点重新生成玩家对象。

5. 联机调优与真实踩坑:延迟、抖动和网络字段的体感修复

做完以上功能,你的FPS项目已经“能玩”了,但离“好玩”还有一段不小的距离。这段距离主要是网络体验问题:延迟高、位置抖动、开枪命中反馈不及时。下面这些坑是我在实际调优过程中踩过的,每一个都对应着一个可以操作的具体修复策略。

5.1 位置同步为什么总是“一卡一卡”的

很多人在联机测试时会发现,别人的角色移动起来一顿一顿的,自己的倒是很流畅。这是因为网络同步是“抽样”性质的:你用固定频率把位置发给服务器,服务器再转发给其他人,接收端拿到的是一串离散的点,中间空档如果直接不管,画面就会跳变。

解决思路有两种,一种是插值(Interpolation),每两个同步点之间用线性或曲线插值补上,让角色看起来匀速运动;另一种是外推(Extrapolation),在最后一次收到的位置基础上,按速度和方向继续推进,效果更顺手,但出错风险也更高。初版建议先用插值,它实现简单、稳定性高。

PurrNet的Transform同步组件自带同步频率选项,我的做法是把它调到一个相对合理的值,比如每秒10到15次同步,然后接收端启用插值。太高的同步频率会浪费带宽,太低的会导致位置误差大,10到15次足够支撑大多数非竞技级FPS的体验。

5.2 开枪命中反馈总是慢半拍

如果你实现了“客户端只发请求,服务器判定后再广播结果”,你会立刻发现一个体感问题:自己开枪后,命中特效和伤害数字要等一个RTT(往返延迟)之后才出现。这在延迟低于50ms时勉强能忍,一旦超过80ms,玩家就会觉得枪特别“肉”。

一个折中的优化方案是“客户端乐观反馈”:本地命中时,立刻在自己屏幕上播放命中效果并扣掉假想的血条,同时把请求发给服务器;服务器判定成功后再校正分数,判定失败则回滚自己的假想数值。这个方案的缺点是可能欺骗玩家,但在非公平竞技的娱乐局里,它带来的手感提升是巨大的。

如果你不想做乐观反馈,还有一个更简单的做法:把命中判定做得“宽容一点”。比如服务器射线检测时,把射线末端半径放大10厘米,允许略微偏离目标的子弹仍然造成伤害。这看起来像是在作弊,实际是给网络误差留出缓冲空间,在很多商业游戏中也是常用策略。

5.3 网络字段刷新频率对性能的影响

NetworkField是PurrNet里特别方便的功能,但也很容易滥用。我的亲身体会是:每个网络字段一旦变化,都会产生额外的序列化和传输开销。如果你在角色脚本里放了十来个NetworkField,并且每帧都在改动,很小的demo也可能出现网络消息洪峰。

我后来给自己定了几条规矩:只同步别人能看到的数据,比如血量、阵营、击杀数、开火事件;本地频繁变化的临时变量,比如准星扩散、跑步动画状态,尽量不设成网络字段,如果必须同步,控制变更频率,例如每0.1秒才允许同步一次。这些约束一开始就定好,后面重构会少很多。

下面这个表格是我在实战中总结的“什么时候该用网络字段,什么时候该用RPC”的判断标准,对新手特别有用:

场景用NetworkField用RPC
血量、分数、角色名是,持续变化,需要实时显示否
扣扳机、爆炸、开枪否是,一次性事件
玩家加入房间否是,玩家列表刷新
位置、旋转是(用Transform同步组件)否
游戏状态切换是可以配合事件通知

5.4 连不上、掉线、对象重复生成这些基础问题

除了体感优化,联机项目还有一堆基础问题容易在关键时刻掉链子。这里我按经验把常见问题排个序。

一是在局域网测试时连不上。先确认防火墙有没有挡掉UDP端口,PurrNet默认用的传输类型需要开放对应端口。二是多人同时生成玩家对象时,有可能出现重复生成,原因是客户端重复调用了生成请求。解决办法是在服务端加一个请求标记:每个客户端同一时间只允许有一个活跃的生成请求,生成成功或失败后立刻复位。三是掉线重连的逻辑。初版可以直接不做断线重连,把掉线当玩家退出处理,但玩家对象释放时,必须确保服务器清理干净,否则服务器会越跑越卡。

我在调试这些网络问题时最大的感受是:不要凭感觉猜,一定要在关键节点加日志。我在服务器端加了命中日志、生成日志、掉线日志,哪一步出了问题,一眼就能看到。大多数“灵异网络问题”,最后都是日志里暴露出的状态顺序错误。


说了这么多,最后分享一点个人体会。用PurrNet做FPS联机,最大的优势不是某个具体API有多强,而是它逼着我用“服务器权威”的思路去重新思考游戏逻辑。很多功能单机时随便写写就能跑,联机时必须把“哪段逻辑属于意图、哪段逻辑属于判定”分得清清楚楚,这种思维方式的转变,比学会任何一个网络库都值钱。

如果你正准备动手,我的建议是先做一个最小可玩的版本:两个玩家、一把枪、一个出生点、一条生命值。不要一开始就想着把爆破模式、排位系统、皮肤系统全做进去,网络层像一个放大器,单机里的小瑕疵在联机里会变成灾难。先把核心链路跑通,再加上房间、重生、计分板,每一步都验证稳定了,再往前推进。按这个节奏,你大概率能做出一个真正能和朋友愉快开黑的FPS项目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询