简介:这是一份基于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 Transport(UTP)把底层的 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 NativeList<NetworkConnection> 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 NativeList<NetworkConnection>(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(); NativeArray<byte> buffer = new NativeArray<byte>((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, NativeArray<byte> 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,画面会一卡一卡地抖。正确做法是把最近两个采样点缓存起来,在当前时间点做线性插值。比如收到 t=0.0s 和 t=0.033s 的两个位置,渲染在 t=0.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; // 0=Run, 1=Jump, 2=Slide } 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 是 Jump,nextState 是 Run, // 根据 t 决定是否切换播放落地动画 } }这段代码的核心是把网络时间戳和本地时间分开处理。快照里带的timestamp是发送端的游戏时间,接收端用renderTime = Time.time - renderDelay把自己“拉回”到过去,就能找到对应的快照区间做插值。这里有个常见误区:有人直接用本地Time.time作为快照时间戳,忽略了发送端和接收端的时间基准不一致,结果插值出来的位置永远在闪回。联机同步必须用发送端时间戳,或者在连接握手时做一次时间校准。
renderDelay的值需要实测。局域网双人联机延迟通常在 10~30ms,这时把renderDelay设成 50ms 就够平滑了;公网联机延迟可能在 50~120ms 波动,100ms 是合理起点。调参的办法是让两个人在不同网络环境下联机,观察远程角色是否出现拖影或瞬移,逐步微调renderDelay。
4.2 双人胜负判定与到达顺序:如何让“我先到”可信
跑酷游戏的核心目标是比对方先到达终点。单机版里胜负判定很简单——角色 x 坐标谁大谁领先。但联网版有一个隐藏问题:双方位置都来自各自的本地权威,如果不做约束,就会互相矛盾。比如玩家 A 说自己 x=98,玩家 B 说自己 x=99,可 B 是 50ms 延迟,A 是 20ms 延迟,谁是真的跑的远?
我用的方案是“里程碑判定”:把赛道每隔 10 米设一个检查点,角色跨越检查点时生成一个带编号的到达记录,发给对手。先到达同一检查点的人,在下一个检查点之前拥有“领先权”。胜负最终以“跨过终点线的时间”为准,服务器记录两个客户端发来的终点到达消息的时间戳,先到的赢。
这里要注意的是,时间戳必须以服务器接收时间为准,不能依赖客户端自报的“我XX秒到了”。客户端自报时间戳很不可靠——本地计时器快了 500ms,谁先到就没法判了。服务器的接收时间无法完全避免作弊,但至少能保证在正常网络环境下,判定结果不受两端各自的时钟偏移影响。我做这个系统时踩过坑:一开始用“双方各自上报到达时间,取较小值”做判定,结果两个玩家都觉得自己赢了,界面同时显示“胜利”和“失败”,改成分段检查点+服务器时间戳之后,这个问题才算是真正解决。
4.3 断线重连与房间恢复:玩家掉线不用从头再来
双人联机跑酷里掉线最让玩家崩溃的是:已经跑了一半,断线重连上来,双方的位置和赛道状态完全对不上。解决思路是“房间状态序列化”:服务器始终保存最近一次完整房间状态快照,包括两个玩家的位置、速度、状态、当前赛道段号和种子值。重连的玩家拿到这份快照,先从快照位置继续跑到下一个检查点,再恢复到正常的实时同步。
快照的保存时机不必每帧都做,每个检查点保存一次就够。一个检查点间隔 10 米,跑完一场 500 米的赛道总共保存 50 份快照。重连后直接恢复最近一份检查点快照,相当于让掉线玩家的进度回退到“最近一个检查点”。这对玩家来说是有代价的——如果掉线时刚跑过检查点,重连后会回退一小段;但比掉线后从起点重新跑要好得多。而且因为障碍物是种子生成的,恢复快照后赛道继续生成也能保持一致性,玩家不会看到障碍物凭空消失或出现。
服务器保存快照的代码逻辑不复杂,在服务器收到两端的位置消息时,顺带把当前状态写入一个Dictionary<int, PlayerSnapshot>,key 是检查点编号,value 是序列化后的玩家状态。重连时按房间号(我一般是四个字母加三位数字生成房间码)找到最近快照,推送给重连客户端。房间码在局域网联机里可以简化成固定房间,公网联机才需要这种动态匹配。
5. 双人联网跑酷避坑:5 个频发问题与排查手法
5.1 现象:两个客户端角色位置偏差越来越大,后来直接穿模
原因:物理引擎在不同帧率下模拟结果不一致。Unity 的FixedUpdate默认固定步长是 0.02s(50Hz),但如果你在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。这个临界点是网络的真实表现,比任何固定值都靠谱。希望这篇笔记能帮你把这个方向的大坑提前填上,祝你联机跑得不翻车。
本文还有配套的精品资源,点击获取