多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Unity3D双人联网跑酷:状态同步与插值重连实战

Unity3D双人联网跑酷:状态同步与插值重连实战 简介这是一份基于Unity3D开发的双人联网跑酷游戏完整工程资源适用于游戏方向的毕业设计、课程设计、大作业或工程实训。项目包含角色移动与跳跃、双人联机同步、跑酷赛道生成、障碍碰撞、得分与UI界面等模块从中可以系统了解网络同步方案、动画状态机配置、预制体管理与场景打包流程。资源共包含2005个文件主要以fbx模型、prefab预制体、cs核心脚本、材质贴图、anim动画等类型构成脚本和预制体便于直接阅读逻辑模型与贴图可直接用于搭建关卡整体压缩包约211MB目录结构按项目设置、场景、脚本分层便于定位和快速二次开发。当前已有280人学习下载适合作为可运行的练手原型也可在现有框架上继续扩展关卡、角色技能与联机玩法。1. 双人联网跑酷跑酷的难点不是跳跃而是双人做“基于unity3d的双人联网跑酷游戏”很多人第一反应是研究跑酷玩法本身跳跃手感、障碍物种类、赛道节奏。但真正落地时你会发现跑酷逻辑单机版跑通只要一两周一旦加上“双人联网”四个字问题就变成了另一个物种——两个人同时在赛道里跑谁先到谁赢那么两个人的位置、速度、跳跃状态、掉落判定每一帧都需要在网络上取得一致。写这篇文章之前我刚帮朋友调完一个双人跑酷demo翻车的不是角色控制而是两台电脑上同一个角色的位置差了半米。这个项目适合想做联机玩法但不想一上来就碰帧同步硬核方案的Unity开发者也适合拿它当毕业设计或游戏 Demo 的从业者——它能让你用最小代价把“联网游戏”的完整链路走一遍从房间到同步到掉线重连全部踩一遍。2. 先定同步架构为什么双人跑酷适合状态同步而不是帧同步2.1 帧同步与状态同步的取舍跑酷场景里状态同步更省心做联网跑酷首先要回答一个架构问题两个人同步的是什么帧同步的答案是“同步输入”所有客户端跑同一个确定性逻辑只要初始状态一致、输入一致表现就一致。这个方案在格斗游戏、RTS里是主流但跑酷游戏有个天然劣势物理引擎参与角色移动。Unity的物理系统在不同帧率、不同平台上的浮点运算结果不是严格确定的同一个跳跃在Windows和Mac上可能差出几厘米。几厘米的偏差在格斗游戏里是大事在跑酷里更是——它直接决定角色是踩上障碍物还是撞上去。状态同步则相反它同步的是“结果”。每个客户端把自己角色的位置、速度、当前状态跑步/跳跃/滑铲发给对方而不是把按键输入发给对方。这个方案的优点是容错性高物理引擎偶尔抖动也没关系因为对端拿到的始终是“你已经在这里”的权威结果而不是“我按了跳跃键”的待定输入。代价是网络包数量比帧同步多一些但对于双人跑酷这种实体数量极少两个人加一堆无状态障碍物的场景这点流量完全不是问题。我一般会建议双人跑酷直接上状态同步理由有两个一是物理参与度高确定性难以保证二是参与者只有两个状态同步的带宽压力可以忽略。群里的朋友劝我用帧同步做“更极客、更公平”我就回他一句你先写个确定性物理再说。做项目不是炫技是选容错率最高的方案。2.2 客户端权威还是服务器权威双人场景选客户端权威的边界确定状态同步之后第二个问题是谁的位置说了算服务器权威的意思是玩家把操作发给服务器服务器计算位置再把结果广播给所有人。这个方案防作弊最好但双人游戏里它有两个麻烦一是需要一台实时计算的服务器成本高二是增加一跳延迟本来两个人直连延迟 20ms走服务器中转变成 50ms跑酷这种对跳跃时机极敏感的游戏多 30ms 就可能让玩家骂娘。客户端权威则相反每个客户端自己算自己的位置直接发给对方服务器只做转发。它的最大问题是作弊——玩家完全可以改本地代码让自己速度翻倍。但在双人好友联机、家用路由器直连或者局域网测试的典型场景里作弊的动机几乎不存在。做毕业设计或者朋友之间联机玩客户端权威是性价比最高的方案。我的折中做法是客户端权威 服务器中转。服务器不计算游戏逻辑只做两个端之间的消息转发和房间匹配。这样既避开 NAT 穿透的坑两个客户端不直连都连服务器又不会让服务器成为性能瓶颈。转发服务器用一台低配云主机就够了跑双人游戏毫无压力。后面所有代码示例都基于这个架构客户端算状态、服务器中转、客户端渲染对方状态。2.3 用 Unity 官方 Transport 实现最小连接与消息通道连网络层用什么很多人第一反应是 Mirror、Photon 或者 Netcode for GameObjects但我的建议是先用 Unity TransportUTP把底层的 UDP 收发跑通。UTP 是 Unity 官方的传输层库封装了可靠 UDP 和不可靠 UDP 通道包体小、无业务逻辑适合理解联网游戏的最底层工作原理。用熟了之后再换上层框架心里也有底。下面是一个基于 UTP 的最简连接示例主机端开启服务客户端连入后进行事件轮询using Unity.Collections; using Unity.Networking.Transport; using Unity.Networking.Transport.Utilities; public class HostServer : IDisposable { private NetworkDriver driver; private NativeListNetworkConnection connections; public HostServer(int port) { // 创建驱动绑定到指定端口 driver NetworkDriver.Create(new NetworkConfigParameter { maxConnectAttempts 10, // 最大连接尝试次数 connectTimeoutMS 3000, // 连接超时 3 秒 maxFrameTimeMS 100 // 单帧最多阻塞 100ms }); driver.Bind(NetworkEndpoint.AnyIpv4.WithPort(port)); driver.Listen(); connections new NativeListNetworkConnection(16, Allocator.Persistent); } public void Update() { // 每帧收集连接事件和网络事件 driver.ScheduleUpdate().Complete(); AcceptNewConnections(); ProcessNetworkEvents(); } private void AcceptNewConnections() { NetworkConnection connection; while ((connection driver.Accept()).IsCreated) { connections.Add(connection); Debug.Log($玩家接入: {connection.GetRemoteEndpoint(driver)}); } } private void ProcessNetworkEvents() { DataStreamReader stream; for (int i 0; i connections.Length; i) { if (!connections[i].IsCreated) continue; NetworkEvent.Type eventType; while ((eventType driver.PopEventForConnection(connections[i], out stream)) ! NetworkEvent.Type.Empty) { if (eventType NetworkEvent.Type.Data) { // 读取消息长度然后读取消息内容 uint length stream.ReadUInt(); NativeArraybyte buffer new NativeArraybyte((int)length, Allocator.Temp); stream.ReadBytes(buffer); // 转发给另一个连接双人场景不需要遍历 BroadcastToOther(connections[i], buffer); buffer.Dispose(); } else if (eventType NetworkEvent.Type.Disconnect) { Debug.Log(玩家断开: connections[i].GetRemoteEndpoint(driver)); connections[i] default(NetworkConnection); } } } } private void BroadcastToOther(NetworkConnection sender, NativeArraybyte data) { // 找到另一个玩家并转发实现服务器中转 for (int i 0; i connections.Length; i) { if (connections[i].IsCreated !connections[i].Equals(sender)) { driver.BeginSend(connections[i], out var writer); writer.WriteUInt((uint)data.Length); writer.WriteBytes(data); driver.EndSend(writer); } } } public void Dispose() { driver.Dispose(); connections.Dispose(); } }这段代码的关键在于driver.ScheduleUpdate().Complete()必须每帧调用UTP 内部的事件循环依赖它来推进连接状态。maxConnectAttempts和connectTimeoutMS决定了玩家连接失败时的等待时长局域网内建议 3 秒足矣公网环境可以放宽到 5 秒。PopEventForConnection返回NetworkEvent.Type.Empty表示该连接没有更多待处理事件所以要用while循环把所有事件取干净否则消息会积压在缓冲区里导致延迟越来越大。客户端侧的代码几乎一样只是用driver.Connect(endpoint)替代Bind Listen然后每帧轮询事件。这里不做客户端代码展开因为事件循环的写法和服务端完全相同区别仅在Connect调用。你先握住这个 UTP 的最小模型后面的同步逻辑都建立在这个收发框架之上。3. 跑酷核心玩法拆解把“跑”拆成状态机与确定性赛道3.1 玩家状态机与输入缓存跳跃手感的根基联机跑酷的玩家控制本质上是一个有限状态机。普通跑酷里角色只有 Idle、Run、Jump、Slide、Dead 几个状态但联机场景多了一个要求状态切换必须能被精确地“打包发送”给对手。你不仅要告诉对方“我在哪”还要告诉对方“我在跳”还是“在滑铲”否则对方看到的只是一个平移的模型动作对不上位置变化穿模就成了家常便饭。状态机设计上要少而清晰状态越多同步包越大、状态切换的边界条件越复杂。我见过一个跑酷项目做了十一个状态结果联机调试时大部分时间都在对齐状态转换的边界。双人跑酷五到六个状态完全够用Idle、Run、Jump、Slide、DoubleJump、Dead。每个状态只关心一件事能不能进入、退出时条件是什么。输入缓存是另一个容易出现问题的点。跑酷游戏里玩家会习惯在落地前提前按跳如果程序严格按“按下瞬间”判定那这个提前量就丢了表现为跳跃总是慢半拍。常见做法是加一个 0.1 秒的输入缓存窗口玩家按下跳跃后不立即执行而是记录这个请求如果角色在接下来 0.1 秒内落地就立即起跳如果超过 0.1 秒请求作废。这个窗口的大小是手感的关键参数0.08 秒会显得生硬0.15 秒会让玩家觉得“我都松手了它还跳”0.1 秒是经验值。3.2 赛道分段与生成参数障碍物数据如何保持一致跑酷游戏的赛道是联机同步最容易忽略的重灾区。如果赛道是手工摆的那双方场景里的障碍物位置天然一致但只要做了程序化生成——比如按积分动态生成新路段——就要保证两端生成规则完全一致否则就是两个人跑在两条赛道上。我的做法是分段生成 种子驱动。把赛道切成等长的段每段 50 米每个段有一个整数种子。生成器接收种子用这个种子初始化UnityEngine.Random或者 System.Random然后按固定顺序生成该段的障碍物布局。这样一来只要两个客户端收到同一组种子值就能生成完全相同的赛道。这里有一个关键点不要在生成过程中用UnityEngine.Random.Range之外的其他随机源更不要把Time.time掺进种子计算。跑酷游戏里最常见的不同步就是一方用了System.Random的全局实例发障碍物另一方又调了一次UnityEngine.Random.InitState顺序一乱赛道就全岔了。确定性的唯一原则是同一种子、同一次调用顺序得到同一段赛道。生成时机也要统一。不是玩家跑到哪里就生成到哪里而是开局时就把当前整条赛道按分段种子全部生成完后续动态拼接的长度由服务器指令决定。这样即使一方的加载速度稍慢最终生成结果也是可预期的。联机时只需要同步“当前段号 该段的种子值”新加入的玩家就能完整重建赛道状态这比同步一整串障碍物坐标列表节省太多。3.3 本地输入采样与远程插值双人视角下的状态采集双人联网跑酷的网络包内容我建议固定为以下结构帧编号、角色水平位置x 轴、垂直位置y 轴、当前速度、角色状态枚举值、当前所在赛道段编号。每个字段都做成定长避免解析时的边界错误。不要传 Vector3 的 z 轴跑酷是 2D 赛道z 轴固定传输它纯粹浪费带宽。本地采样频率建议与物理更新频率保持一致固定 30 次/秒。也就是说每 33ms 采样一次角色状态打包发出而不是每帧都发——因为 60 帧渲染下每帧都发服务器转发的消息量会翻倍而且接收端 60 次/秒的位置更新用肉眼根本区分不出来白白增加丢包概率。接收端的处理方式叫“插值显示”。对方发来的位置是 30Hz 的采样点如果直接赋值给角色 Transform画面会一卡一卡地抖。正确做法是把最近两个采样点缓存起来在当前时间点做线性插值。比如收到 t0.0s 和 t0.033s 的两个位置渲染在 t0.016s 这一帧时角色显示位置就是两点的中点。这就是快照插值跑酷联机最基础也最关键的渲染策略。这里先抛一个思路具体参数调优放在第 4 章展开。4. 双人实时同步落地位置插值、胜负判定与掉线恢复4.1 快照插值与延迟参数把 30Hz 的采样点变成 60fps 的平滑动画接收端拿到对方的位置快照后要做两层处理先是“缓冲”再是“插值”。缓冲的含义是收到的快照不立即使用而是在队列里攒几个渲染时取“当前时间往前推一个固定延迟”处的快照插值。为什么要攒因为网络有抖动如果每收到一个快照就立即显示那么对方位置会随着延迟波动前后抖动看起来像在“瞬移”。攒三个快照相当于把播放时间往后推了约 100ms网络抖动小于这个值的时候画面就是平滑的。我常用的参数是缓冲区 3~4 个快照对应约 100~130ms 延迟。延迟越低越跟手但缓冲区太小遇到网络尖刺就卡顿缓冲区太大操作感变肉。微信语音电话的延迟大约在 200ms 以内还能接受跑酷游戏我建议把缓冲区控制在 150ms 以下超过这个值玩家会明显感觉“按下跳跃要等一下才跳”。插值对象不要直接用 Transform。我的做法是给远程角色一个独立的“显示节点”用一个脚本管理插值目标using UnityEngine; public class RemotePlayerInterpolator : MonoBehaviour { private struct Snapshot { public float timestamp; public Vector2 position; public float speed; public int state; // 0Run, 1Jump, 2Slide } private Snapshot[] buffer new Snapshot[8]; private int bufferCount 0; private int head 0; private float renderDelay 0.1f; // 100ms 缓冲延迟 public void PushSnapshot(float time, Vector2 pos, float speed, int state) { if (bufferCount buffer.Length) { buffer[bufferCount] new Snapshot { timestamp time, position pos, speed speed, state state }; } else { buffer[head] new Snapshot { timestamp time, position pos, speed speed, state state }; head (head 1) % buffer.Length; } } private void Update() { if (bufferCount 2) return; // 快照不够什么都不显示 float renderTime Time.time - renderDelay; Snapshot prev buffer[head]; Snapshot next buffer[(head 1) % buffer.Length]; // 找到 renderTime 落在哪两个快照之间 for (int i 0; i bufferCount - 1; i) { int idx (head i) % buffer.Length; int nextIdx (head i 1) % buffer.Length; if (buffer[idx].timestamp renderTime buffer[nextIdx].timestamp renderTime) { prev buffer[idx]; next buffer[nextIdx]; break; } } float t Mathf.Clamp01((renderTime - prev.timestamp) / (next.timestamp - prev.timestamp 0.0001f)); transform.position Vector2.Lerp(prev.position, next.position, t); // 状态切换时播放对应动画 UpdateAnimator(prev.state, next.state, t); } private void UpdateAnimator(int prevState, int nextState, float t) { // 这里做动画过渡比如 prevState 是 JumpnextState 是 Run // 根据 t 决定是否切换播放落地动画 } }这段代码的核心是把网络时间戳和本地时间分开处理。快照里带的timestamp是发送端的游戏时间接收端用renderTime Time.time - renderDelay把自己“拉回”到过去就能找到对应的快照区间做插值。这里有个常见误区有人直接用本地Time.time作为快照时间戳忽略了发送端和接收端的时间基准不一致结果插值出来的位置永远在闪回。联机同步必须用发送端时间戳或者在连接握手时做一次时间校准。renderDelay的值需要实测。局域网双人联机延迟通常在 10~30ms这时把renderDelay设成 50ms 就够平滑了公网联机延迟可能在 50~120ms 波动100ms 是合理起点。调参的办法是让两个人在不同网络环境下联机观察远程角色是否出现拖影或瞬移逐步微调renderDelay。4.2 双人胜负判定与到达顺序如何让“我先到”可信跑酷游戏的核心目标是比对方先到达终点。单机版里胜负判定很简单——角色 x 坐标谁大谁领先。但联网版有一个隐藏问题双方位置都来自各自的本地权威如果不做约束就会互相矛盾。比如玩家 A 说自己 x98玩家 B 说自己 x99可 B 是 50ms 延迟A 是 20ms 延迟谁是真的跑的远我用的方案是“里程碑判定”把赛道每隔 10 米设一个检查点角色跨越检查点时生成一个带编号的到达记录发给对手。先到达同一检查点的人在下一个检查点之前拥有“领先权”。胜负最终以“跨过终点线的时间”为准服务器记录两个客户端发来的终点到达消息的时间戳先到的赢。这里要注意的是时间戳必须以服务器接收时间为准不能依赖客户端自报的“我XX秒到了”。客户端自报时间戳很不可靠——本地计时器快了 500ms谁先到就没法判了。服务器的接收时间无法完全避免作弊但至少能保证在正常网络环境下判定结果不受两端各自的时钟偏移影响。我做这个系统时踩过坑一开始用“双方各自上报到达时间取较小值”做判定结果两个玩家都觉得自己赢了界面同时显示“胜利”和“失败”改成分段检查点服务器时间戳之后这个问题才算是真正解决。4.3 断线重连与房间恢复玩家掉线不用从头再来双人联机跑酷里掉线最让玩家崩溃的是已经跑了一半断线重连上来双方的位置和赛道状态完全对不上。解决思路是“房间状态序列化”服务器始终保存最近一次完整房间状态快照包括两个玩家的位置、速度、状态、当前赛道段号和种子值。重连的玩家拿到这份快照先从快照位置继续跑到下一个检查点再恢复到正常的实时同步。快照的保存时机不必每帧都做每个检查点保存一次就够。一个检查点间隔 10 米跑完一场 500 米的赛道总共保存 50 份快照。重连后直接恢复最近一份检查点快照相当于让掉线玩家的进度回退到“最近一个检查点”。这对玩家来说是有代价的——如果掉线时刚跑过检查点重连后会回退一小段但比掉线后从起点重新跑要好得多。而且因为障碍物是种子生成的恢复快照后赛道继续生成也能保持一致性玩家不会看到障碍物凭空消失或出现。服务器保存快照的代码逻辑不复杂在服务器收到两端的位置消息时顺带把当前状态写入一个Dictionaryint, PlayerSnapshotkey 是检查点编号value 是序列化后的玩家状态。重连时按房间号我一般是四个字母加三位数字生成房间码找到最近快照推送给重连客户端。房间码在局域网联机里可以简化成固定房间公网联机才需要这种动态匹配。5. 双人联网跑酷避坑5 个频发问题与排查手法5.1 现象两个客户端角色位置偏差越来越大后来直接穿模原因物理引擎在不同帧率下模拟结果不一致。Unity 的FixedUpdate默认固定步长是 0.02s50Hz但如果你在Update里直接修改刚体位置或速度就会破坏固定步长的确定性。再者两端的帧率不同一人 144Hz一人 60Hz物理步长相同但FixedUpdate的执行次数受帧率影响导致角色位移路径产生细小的分歧时间一长就累积成可见偏差。解决把角色移动完全交给FixedUpdate 刚体禁止在Update中直接改transform.position。另一个关键做法是开启Physics.autoSimulation false手动按固定步长调Physics.Simulate()确保两个客户端物理模拟次数一致。跑酷本来只需要刚体做跳跃落地检测对物理精度要求不高手动控制模拟频率最稳妥。5.2 现象局域网延迟 10ms但对手的跳跃动作总是比本地慢一拍原因插值缓冲区的renderDelay设置成了固定值 100ms完全没有考虑实际网络延迟。局域网场景下 100ms 的缓冲是多余的——实际 RTT 可能只有 20ms白白加了 80ms 的视觉延迟。解决把renderDelay改成动态值每 2 秒测量一次两端的 RTT然后取RTT / 2 固定余量(30ms)作为缓冲延迟。RTT 的测量方式很简单客户端定时发一个 Ping 包服务器收到后立即原样返回客户端记录往返时间取最近 10 次的中位数而不是平均值避免单次抖动污染结果。5.3 现象同一个赛道两个客户端生成的障碍物位置完全不同原因生成器用了全局UnityEngine.Random而两端的调用顺序不一致。比如一端加载场景时插了一段其他 UI 逻辑触发了随机数另一端没有随机数序列就岔开了。赛道分段里的种子虽然一致但Random.Range的取值顺序被污染了。解决生成障碍物时代码里显式初始化一个局部Random不共用全局实例。比如用Random.State保存一个局部的随机数流每次生成赛段时从种子恢复状态生成完再存回状态。不要依赖任何全局的UnityEngine.Random实例。5.4 现象公网联机时客户端直接连不上主机但局域网一切正常原因双方的 NAT 网关阻断了 UDP 连接。双人场景里客户端和主机不在同一局域网时只有一方有公网 IP 或者双方都在对称 NAT 后面客户端发起的 UDP 包无法穿透网关。解决不搞直连改成服务器中转。把第 2 章里的 UTP 示例部署在一台有公网 IP 的低配云服务器上两个客户端都主动连接到这台服务器服务器做转发。这是双人联网跑酷绕开 NAT 穿透成本最低的方式缺点是服务器带宽有限但对于双人游戏完全够用。如果预算实在为零也可以用局域网联机做演示——但公网联机必须要服务器中转。5.5 现象双人联机时一个玩家掉线重连后两个玩家互相看不见对方的角色原因重连的玩家没有把“本地权威状态”同步给仍在线的玩家。服务器只转发了快照给重连方没告诉在线方“有新玩家加入”或“对方重连了”在线方还在等待一个永远不会到达的消息通道。解决重连后必须触发一次全量状态同步——不仅是给重连方下发快照还要让在线的玩家收到“对方已重连”的指令双方重新开始互发位置快照。我在代码里用的是重连方先发一个RejoinMessage服务器收到后通知在线方在线方收到后回一个ResyncMessage包含自己当前状态重连方收到后再发一次自己的状态完成双向握手。没做这一步之前重连等于白连做完之后重连恢复时间大约 1 秒。6. 进阶用 RTT 动态插值与预测式跳跃把联机体验拉高前面的插值方案能解决“平滑显示”但平滑和“跟手”是两回事。双人跑酷里真正影响体验的是“我按下跳跃我自己的角色立即跳起了本地权威没问题同时我看到对手的跳跃动作有着合理的提前量”。这里的关键在预测你不能等对手的位置快照到了才渲染跳跃要在对手的跳跃快照还没到的时候提前推测他正在跳。具体做法是给远程玩家加一层“预测推进”。当收到一个快照时如果快照显示对手在八米前位置、速度是 8m/s那么接下来 100ms 内即使没有新快照也按这个速度推进位置。新快照到达后把预测位置与真实快照位置做一次差值收敛纠正。实现上就是在插值循环里多加一个“外推分支”// 当缓冲区为空时按最后已知速度外推 if (bufferCount 0 hasLastSnapshot) { float elapsed Time.time - lastSnapshot.timestamp; transform.position lastSnapshot.position lastSnapshot.speed * Vector2.right * elapsed; return; }这个外推逻辑能显著降低网络抖动带来的卡顿感——前提是外推时间不超过 200ms超过后动作会明显漂移。另一个进阶技巧是“跳跃前瞻”跑酷游戏里对手是否起跳是可以预测的——当对手的位置接近障碍物且速度未减时概率上他会跳。你可以让远程角色的动画在快照还没到达时就提前播跳跃姿势等位置快照到了再对齐位置。这个小技巧能把双人联机的观感提升一个档次代价是动画偶尔会闪回需要权衡。我做双人跑酷时最深的教训是不要开场就把插值缓冲设成 200ms 求稳那样会让整个游戏像隔着一层雾在操作。正确姿势是先按 RTT 动态调renderDelay然后逐步减余量减到远程角色刚出现轻微抖动的临界点再往回加 10ms。这个临界点是网络的真实表现比任何固定值都靠谱。希望这篇笔记能帮你把这个方向的大坑提前填上祝你联机跑得不翻车。本文还有配套的精品资源点击获取
返回列表