
简介一份基于Unity3D的双人联网跑酷游戏完整工程包面向游戏开发小白、进阶学习者以及需要毕设、课程设计或工程实训的人群。资源以完整Unity项目形式呈现包含双人跑酷核心玩法、角色控制、场景搭建、碰撞检测、动画交互与联网同步模块。压缩包共2005个文件主要类型有fbx模型、prefab预制体、cs脚本、mat材质、anim动画、unity场景和asset配置等整体约211MB。目录结构完整清晰可快速定位功能模块。已有280人学习下载。借助该工程读者可掌握Unity项目的资源组织方式、双人联网通信的基本思路理解动画状态机、UI交互和物理系统等实用开发环节既能直接运行体验完整流程也能基于现有代码进行二次开发与功能扩展适合作为项目立项或综合实践的参考蓝本。1. 双人联网跑酷先做单人再谈同步在 Unity3D 里做一款双人联网跑酷游戏听起来像把单机跑酷加上一个局域网联机功能实际动手后你会发现跑酷玩法的物理判定、镜头跟随、轨道切换本身就是一套完整逻辑而联网同步则是另一套完全不同的系统。如果把联机想成「把两个玩家放在同一个世界里比赛」出发点上就已经错了——真正的做法是先有一个精确定义的单人跑酷核心再决定用「状态同步」还是「帧同步」把第二个人接进来。本文从零拆解这条完整链路从跑酷原型、双人互动机制到基于 Unity Transport 的状态同步实现、帧同步的确定性陷阱最后给出 NAT 穿透、断线重连、插值平滑这些必踩的坑。适合已经能独立做出单机跑酷demo、正准备加联机的开发者也适合想评估「双人联网跑酷」这个方向成本与风险的技术负责人。2. 跑酷玩法原型把三连跳和滑铲做成单机闭环2.1 跑酷的判定模型为什么不能用 Rigidbody 做角色控制很多第一次做跑酷的人会把角色当普通平台跳跃角色用 Rigidbody 加力、加碰撞体、调摩擦系数然后发现自己陷入弹簧效应、抖动、撞墙反弹的无底洞。跑酷玩法的本质不是物理模拟而是「轨道 状态机」。玩家在一条固定轨道上只有三种操作跳跃、滑铲、切换轨道。角色在轨道上持续前进速度恒定或缓增所以根本不需要物理引擎参与角色控制——物理引擎留给落石、旋转障碍、动态平台这类需要真实碰撞的物件就行。我一般把角色做成一个空的 GameObject挂一个 CharacterController 或一个自定义的 MoveController速度、跳跃高度、下落加速度全部写成显式参数在 FixedUpdate 里逐帧计算。public class RunnerMove : MonoBehaviour { public float forwardSpeed 8f; // 前进速度之后由关卡难度曲线驱动 public float jumpVelocity 6f; // 起跳瞬间的垂直速度 public float gravity -18f; // 比物理引擎默认 -9.81 更重手感更脆 public float laneWidth 2f; // 相邻轨道间距 private float verticalVel; private int currentLane 1; // 0/1/2 对应左中右三条轨道 private bool isGrounded true; void FixedUpdate() { // 前进方向匀速位移 transform.Translate(Vector3.forward * forwardSpeed * Time.fixedDeltaTime); // 垂直方向重力加速度 verticalVel gravity * Time.fixedDeltaTime; transform.Translate(Vector3.up * verticalVel * Time.fixedDeltaTime); } public void Jump() { if (isGrounded) { verticalVel jumpVelocity; isGrounded false; } } public void Slide() { /* 切换为滑铲碰撞体详见后文 */ } public void SwitchLane(int direction) { int targetLane Mathf.Clamp(currentLane direction, 0, 2); if (targetLane ! currentLane) { currentLane targetLane; // 轨道切换用水平补间不用瞬移 StartCoroutine(SmoothLaneChange(direction * laneWidth)); } } }这套代码的关键在于前进、跳跃、轨道切换三者完全解耦前进是匀速位移跳跃是独立的垂直速度积分轨道切换是协程补间。这样写的好处是「可预测性」——同一帧下发同一组操作每个客户端的角色位置完全一致这正是后面帧同步能够成立的基础。如果你用了 Rigidbody物理引擎的浮点运算和碰撞响应在每台机器上会有细微差异联网之后就会积累成肉眼可见的偏移。跳跃高度和重力这两个参数严重依赖手感我的经验值是跳跃持续时间控制在 0.65 秒左右滞空最高点约为 2.1 米这样刚好能越过普通高度的障碍物又不会让玩家觉得飘。注意gravity是负值且绝对值大于现实重力这是为了让下落比上升快形成「砸下去」的干脆手感。2.2 轨道切换与障碍物双人互动的第一个设计支点单机跑酷做完移动控制后要马上加入障碍物和轨道切换否则就没有跑酷节奏。障碍物分三类地面障碍需要跳跃、高空横梁需要滑铲、全宽挡板需要切换轨道。用 Collider 加 isTrigger 做检测进入触发器范围后按当前状态判定是否死亡或扣血。双人联网跑酷和单人最大的不同是你需要重新设计障碍物的出现逻辑。如果两个玩家共享同一条障碍序列那么落后的玩家必然面对已经被领先玩家触发的障碍体验极差。常见做法是「分轨障碍」——每一轨道的障碍独立生成玩家切换轨道等于切换一条属于自己的障碍流。另一种做法是双人协作某些特殊障碍只能由其中一个玩家触发开关来消除这给联网同步提出了额外需求一方的操作要实时影响另一方的场景物体。做双人模式前我建议先把单人跑酷玩到「三分钟不重样」即障碍序列生成规则有随机性、有难度曲线、有节奏变化。推荐用「障碍物模式表」加「伪随机种子」预定义几十种障碍组合每个组合代表 3 到 5 个连续障碍物生成时按权重挑选同时保证连续两次不出现相同模式。public class ObstacleSpawner : MonoBehaviour { public GameObject[] obstacleTemplates; public float spawnInterval 2.2f; public int randomSeed 12345; private System.Random rng; void Start() { rng new System.Random(randomSeed); InvokeRepeating(nameof(SpawnNext), 2f, spawnInterval); } void SpawnNext() { int index rng.Next(obstacleTemplates.Length); // 这里特意不用 Unity 的 Random而是 System.Random // 因为同样的种子在任何平台上必须产出相同的障碍序列。 var obj Instantiate(obstacleTemplates[index], spawnPoint.position, Quaternion.identity); // 把障碍物挂到轨道父节点下方便整体回收 obj.transform.SetParent(currentTrackAnchor, false); } }这里必须强调System.Random和UnityEngine.Random的选择不是随便写的。Unity 的 Random 在编辑器与真机、不同版本之间不能保证种子序列的一致而帧同步需要所有客户端生成完全相同的障碍序列。用System.Random配合固定种子是这一环节的硬性要求。如果你后面发现联机时两个人看到的障碍位置不一样先检查这里。2.3 滑铲碰撞体切换的正确姿势滑铲的实现细节不多但坑不少。很多人直接用transform.localScale把角色压扁这在单机里勉强能用在联网场景下同步碰撞体边界会变成噩梦。正确的做法是把「站姿碰撞体」和「滑铲碰撞体」做成两个独立组件切换时启用一个、禁用另一个。public class SlideController : MonoBehaviour { public Collider standCollider; public Collider slideCollider; public float slideDuration 1.4f; private float slideTimer; public void BeginSlide() { standCollider.enabled false; slideCollider.enabled true; slideTimer slideDuration; } void Update() { if (slideTimer 0) { slideTimer - Time.deltaTime; if (slideTimer 0) { standCollider.enabled true; slideCollider.enabled false; } } } }滑铲碰撞体设置为一个高度约为站姿 1/3 的盒子中心下移。注意切换碰撞体后角色视觉模型也要同步压低否则会出现「明明蹲下了模型还在站着」的视觉错位。这个状态在联网同步时需要作为一个布尔值传给对端后面会详细讲。3. 选型与网络架构为什么客户端-服务器比 P2P 更省心3.1 网络库对比Unity Transport、Mirror、Netcode for GameObjects确定单人核心之后第二步是选网络层。Unity3D 做联机有三个主流选择Unity TransportUTP底层传输库、Mirror社区分支稳定性经过大量项目验证、Netcode for GameObjectsUnity 官方高级 API。我自己的项目路径是先用 UTP 做最小验证因为它是传输层没有业务逻辑学一遍就能理解数据怎么往来然后再包一层自己的消息协议这样比直接用 Mirror 更容易定位问题。Mirror 和 NGO 本质上是「带同步组件的网络框架」它们帮你把 Transform、Animator 的状态自动广播开发快但封装层较厚出了问题不好查。对于跑酷这类「状态简单、同步精度要求高」的游戏我建议用 UTP 加自定义消息。UTP 的 API 风格接近底层 Socket把收发包工作放在一个 NetworkManager 单例里管理。using Unity.Collections; using Unity.Networking.Transport; public class NetManager : MonoBehaviour { private NetworkDriver driver; private NetworkConnection serverConn; private bool isServer; public void StartServer(ushort port 7777) { driver NetworkDriver.Create(); var endpoint NetworkEndpoint.AnyIpv4; endpoint.Port port; if (driver.Bind(endpoint) ! 0) return; driver.Listen(); isServer true; } public void ConnectToServer(string ip, ushort port 7777) { driver NetworkDriver.Create(); var endpoint NetworkEndpoint.Parse(ip, port); serverConn driver.Connect(endpoint); } void Update() { driver.ScheduleUpdate().Complete(); if (isServer) { // 服务端每帧处理连接和收包 AcceptAndReceive(); } else { // 客户端收包 ReceiveFromServer(); } } }这段代码只是骨架但它说明了 UTP 的基本模型NetworkDriver是唯一入口收发都在Update里完成且必须在主线程调用。UTP 是值类型驱动没有 GC 压力但代价是「手工管理连接生命周期、收发 buffer、断线重连、粘包拆包」。如果你不想处理这些底层的麻烦直接上 Mirror 会快很多但你要接受 Mirror 在某些情况下比如频繁加入退出、主机迁移也会出怪问题。3.2 同步策略状态同步与帧同步的边界这是整个双人联网跑酷项目里最核心的架构决策。两条路线在业界都有成熟案例但要依据玩法的「判定密度」来选择。状态同步服务端拥有权威状态客户端只发送输入服务端计算后广播结果。玩家位置、朝向、生命值都实时广播。优点反作弊强、掉线恢复容易缺点带宽占用高两个玩家的位置刷新频率受网络延迟影响动作类游戏很容易出现「我明明跳了对面看我还是原地」。帧同步客户端只交换「操作指令」比如帧序号 跳跃/滑铲/切换轨道每个客户端用完全相同的逻辑跑完整帧模拟。优点带宽极低、同步精度高缺点要求所有客户端逻辑完全确定确定性浮点、确定性随机、确定性物理任何一点不一致都会导致两边世界逐渐分岔。跑酷玩法的判定密度很高跳跃/滑铲/切换轨道每帧都可能发生但角色移动逻辑简单匀速 离散状态所以帧同步是更合适的选择。状态同步适合 FPS/TPS 这类需要服务端做碰撞验算的游戏。我的结论双人跑酷用帧同步但把「场景物体动态变化」这一部分单独用状态同步补充因为障碍物由服务端控制生成服务器直接下发障碍物位置列表比让所有客户端用相同随机种子生成更省心力。3.3 帧同步最小实现锁步协议与输入队列帧同步的核心是「锁步」所有客户端在同一帧执行同一系列输入。实现方式是把游戏逻辑抽出为纯函数输入按帧打包每帧结束确认所有客户端都收到了下一帧的输入才继续推进。public class FrameSyncCore { private QueuePlayerInput[] inputQueues; // 每个玩家一个队列 private int currentFrame; public const int FixedFrameRate 30; public void PushInput(int playerId, PlayerInput input) { inputQueues[playerId].Enqueue(input); } public bool CanStepForward() { for (int i 0; i inputQueues.Length; i) { // 所有玩家至少有一帧输入才能推进 if (inputQueues[i].Count 0) return false; } return true; } public void StepFrame() { if (!CanStepForward()) return; PlayerInput[] frameInputs new PlayerInput[inputQueues.Length]; for (int i 0; i inputQueues.Length; i) { frameInputs[i] inputQueues[i].Dequeue(); } // 把本帧输入交给游戏逻辑核心执行 GameLogicRunner.Step(currentFrame, frameInputs); currentFrame; } }GameLogicRunner.Step是你整个游戏逻辑的唯一入口。所有移动、障碍物碰撞、得分、生命值变化都在这个纯函数里完成它不访问任何外部组件不调用 Physics 引擎只依赖传入的帧输入和内部状态。这是帧同步最基本的要求逻辑可重放。你需要在编辑器里做一个回放工具把一整局的所有帧输入记录下来然后离线回放验证双方的游戏结果一致。实操上有几个必须处理的细节。第一输入要带帧号防止网络乱序导致帧内容错位第二客户端要有输入缓冲至少缓存 3 帧的输入防止网络抖动导致本机无法推进第三如果一方掉线导致帧推进停滞需要超时机制判定「同步失败」并结束对局。4. 双人联网的玩法实现把「同步」变成「有趣」4.1 局域网联机测试最便宜的验证闭环在没有公网服务器之前先用局域网验证核心同步逻辑是最快的路径。同一台路由器下两台电脑、或者同一台电脑开两个 Unity Editor 实例都可以跑。启动顺序有讲究先开服务端第一个玩家创建房间再开客户端第二个玩家输入 IP 连接。在编辑器里调试多开时注意分屏执行。用-screen-width和-screen-height启动参数强制不同窗口大小否则两个 Game 视图叠在一起很混乱。测试时要盯三个指标帧同步是否一致、操作延迟是否可感知、断线后对端是否卡死。# 第一个实例作为服务端 /Applications/Unity/Hub/Editor/6000.0.23f1/Unity.app/Contents/MacOS/Unity \ -projectPath ./RunGame -screen-width 1280 -screen-height 720 # 第二个实例作为客户端 /Applications/Unity/Hub/Editor/6000.0.23f1/Unity.app/Contents/MacOS/Unity \ -projectPath ./RunGame -screen-width 1280 -screen-height 720局域网测试通过不意味着广域网可用。局域网延迟通常小于 5ms丢包率接近 0而公网环境下 50ms 到 100ms 是常态丢包 1% 到 5% 都很正常。帧同步对延迟极其敏感一帧 33ms端到端往返 100ms 意味着客户端至少要缓冲 3 帧才能稳定推进。这个缓冲值要写成可调参数发布前用网络模拟工具如 Clumsy或者 Unity 的模拟器测试最差情况。4.2 两人竞速还是协作玩法设计影响同步复杂度明确了同步方案回头再设计双人玩法时就会有清晰边界。两种主流模式竞速模式两个玩家在同一轨道流里比赛先到终点或先攒够分数者胜。此模式障碍物序列对所有玩家一致帧同步最省力唯一要处理的是落后玩家的「追赶机制」——给落后方短暂的加速 buff 或更短的障碍生成间隔让比赛保持张力。协作模式两人必须配合开关某些装置才能通过特定关卡段比如 A 玩家踩下压力板B 玩家前方的高台才会降下来。这种玩法本质上要求实时状态同步压力板状态、高台位置单纯帧同步无法优雅处理因为场景物体的运动和角色逻辑不在同一个确定性模型里。我的建议是协作场景按「关卡状态机」实现压力板状态变化通过状态同步广播而非帧同步模拟。4.3 消息协议设计用一份枚举表统一收发帧同步中所有客户端之间传输的只有消息类型和参数。设计协议时建议把它做成一个独立文件所有消息结构体对应同一个字节流布局。C# 这边用二进制读写注意这里不能用 Json 或字符串—— 一帧 30 次同步Json 的序列化开销和 GC 压力会让帧同步崩掉。public enum GameMsgType : byte { JoinRequest 1, JoinAccept 2, FrameInput 3, GameState 4, Heartbeat 5, } public struct PlayerInput { public uint frame; public bool jump; public bool slide; public sbyte laneDir; // -1/0/1 public void Serialize(DataStreamWriter writer) { writer.WriteUInt(frame); writer.WriteByte((byte)(jump ? 1 : 0)); writer.WriteByte((byte)(slide ? 1 : 0)); writer.WriteSByte(laneDir); } public static PlayerInput Deserialize(DataStreamReader reader) { return new PlayerInput { frame reader.ReadUInt(), jump reader.ReadByte() 1, slide reader.ReadByte() 1, laneDir reader.ReadSByte(), }; } }写协议时第一原则是固定长度、固定顺序不要出现「按字符串长度变长」的字段。变长字段在粘包时难处理也容易埋下解析越界 bug。第二原则是字段越小越好跳、滑、方向各占一字节一帧输入 6 字节两个玩家 30 帧每秒才 360 字节——这就是帧同步在带宽上的巨大优势。5. 双人联网的避坑清单从局域网到公网的血泪经验5.1 NAT 穿透失败为什么两人不在同一路由器就永远连不上现象局域网联机一切正常换成两台不同网络下的电脑客户端填了服务端的公网 IP就是连不上。原因大多数家庭路由器都在做 NAT网络地址转换内网设备的 IP 在公网不可见服务端虽然绑定了0.0.0.0监听但公网数据包根本进不来。解决先问对方要一个公网 IP 的 VPS一般叫云主机把服务端跑在云主机上两个客户端都主动连接云主机。双人游戏在大陆网络环境下如果走玩家之间直连八成会卡在 NAT。这里不做任何相关工具或服务的推荐只说明架构层面的选择——云主机中转是工程上最稳定、代码改动最小的方式代价是增加一跳网络延迟。提示如果两人都在同一个局域网直连没问题一旦跨网络请直接放弃 P2P 直连把服务端部署到有公网 IP 的机器上。这是我在双人联机项目上踩过最重的一次坑。5.2 动态障碍物的帧同步分岔服务器生成与客户端生成的后果现象对局中第 30 秒开始两个玩家看到的障碍位置出现偏移越往后越严重有人觉得「我这边的障碍物根本没按规则出现」之前提到用 System.Random 做生成器但如果你把这个生成器放在每个客户端独立运行起始种子一样、生成时机一样理论上是不会分岔的。分岔的真正来源是「生成时机」——如果你用InvokeRepeating或者Update里的计时器触发生成而计时器的起点受帧率影响两边的spawnInterval累积就会不同。解决把障碍生成逻辑彻底移出实时更新。正确做法是每局开始前由服务端或主机生成好整个障碍物序列作为「赛道清单」分发给所有客户端。客户端只负责按当前帧号取对应的障碍物并实例化生成时机不再取决于本地计时器而是由帧号唯一决定。public class TrackDefinition { public int levelId; public int[] obstaclePatternIds; // 索引 第几组障碍 public int seed; public ObstaclePattern GetPatternAtFrame(int frame) { int patternIndex frame / framesPerPattern; return LoadPattern(obstaclePatternIds[patternIndex]); } }这个数据结构是帧同步的安全阀。赛道清单固定后所有客户端的障碍生成序列完全一致不再受任何计时器影响。你甚至可以离线回放一整局验证两侧的最终比赛结果。5.3 跳跃延迟判定与帧缓冲抖动为什么手感忽好忽坏现象单机测试时跳跃跟手联网后跳跃高度明显降低、或同一次跳跃有时高有时低。原因帧同步需要输入缓冲客户端按帧号发送输入但服务端或主机推进逻辑的节奏不是恒定的。如果 Player A 的输入到达时间抖动本机逻辑可能已经在等待中错过最佳跳跃点或因为输入缓冲不够而丢帧。解决把「输入产生」和「输入执行」分离。客户端本地永远不直接执行角色移动只把输入写入队列并立即返回——也就是常说的「本地预测不做纯锁步」。跳跃高度由帧同步逻辑统一计算客户端需要做的是在本地播放跳跃动画时用逻辑计算的位置做视觉插值但不要用视觉位置反推判定。同时把输入缓冲从 3 帧调大比如 5 帧用提高延迟稳定性来换手感一致性。注意帧同步跑酷里跳跃高度的参数虽然写在角色控制器里但实际数值归属帧同步逻辑核心。你改变 buffered frames 后手感会变化需要重新调跳跃高度和重力建议做成配置文件反复测试不推荐在代码里硬编码。5.4 服务器物理机时间偏差帧号漂移导致的回放不一致现象客户端 A 的帧号比客户端 B 慢 2 帧短时间内没问题但长时间对局后 A 总是慢半拍甚至在关键障碍前出现「撞空气」现象。原因各机器的Time.deltaTime累积误差不同帧同步用固定步长推进理论上不会漂移但如果你不小心把逻辑放在Update而非FixedUpdate就会出问题。帧同步逻辑必须只挂在FixedUpdate且Time.fixedDeltaTime必须在所有客户端设置完全一致。void Awake() { // 强制固定帧率避免设备性能差异导致逻辑步长不稳定 Time.fixedDeltaTime 1f / 30f; QualitySettings.vSyncCount 0; Application.targetFrameRate 60; }把上述代码放在游戏启动第一帧执行。帧同步对固定步长的依赖是无条件的任何「这一帧多跑一点、下一帧少跑一点」都会制造分岔。5.5 断线重连的后悔药快照系统和Player ID设计现象一个玩家中途断线游戏直接失败退出重开体验非常打击。原因帧同步只保留当前输入和状态没有历史快照断线玩家的位置无法恢复。解决在同步逻辑里周期性保存「全量快照」包括所有玩家的帧号、位置、速度、障碍物索引、分数。断线玩家重连时向其发送最新快照然后客户端的帧同步核心从快照状态继续推进其间的输入由重连后的新输入补齐。这会引入额外的开发量快照序列化、快照压缩、半同步期间的输入插值但对于一款面向真实玩家的联网游戏这个是必要的工程投入。我通常在帧同步核心外加一层SnapshotManager每隔 30 帧即每秒存一次快照最多保留最近 10 个重连时选择一个离断线点最近的快照恢复。快的多了是作弊慢的多了是卡顿30 帧一次比较均衡。6. 节奏同步与性能让双人跑起来不斗嘴的最后一公里双人联网跑酷的最终体验取决于「两个玩家在各自屏幕上看到的比赛节奏是否一致」。帧同步解决了逻辑一致性但视觉呈现还需要一层「平滑」。跑酷角色的位置是逻辑核心算出来的离散值如果直接 set 到 Transform 上在低帧率设备上会看到抖动。常见做法是「逻辑层 表现层」分离逻辑层用固定步长跑出位置表现层在当前逻辑位置和目标逻辑位置之间做插值。public class VisualSmoother : MonoBehaviour { public Transform visualRoot; public float lerpSpeed 12f; private Vector3 targetPos; public void SetTargetPosition(Vector3 pos) { targetPos pos; } void LateUpdate() { // 只做视觉插值绝不参与逻辑判定 visualRoot.position Vector3.Lerp(visualRoot.position, targetPos, lerpSpeed * Time.deltaTime); } }这里插值速度调到 10 到 15 之间比较合适太慢会出现「角色像在滑冰」视觉位置落后逻辑位置太多太快则插值失去平滑作用抖动感又回来了。注意插值是表现层的事逻辑层的坐标必须精确不能把插值后的坐标回填给帧同步核心。性能侧跑酷场景的最大压力通常来自大量障碍物的实例化和销毁。每帧生成新障碍、销毁旧障碍会产生大量 GC 和 CPU 峰值。方案是对象池预生成 50 个障碍物体按需激活和回收而不是 Instantiate/Destroy 来回切。对移动设备来说这一项优化效果比任何其他设置都更直观。我自己的习惯是每做完一个联机功能必做一次「双机长跑测试」两台设备跑 10 分钟以上然后对比两端的比赛回放数据是否一致、分数是否相同、障碍物位置是否重合。这个验证步骤看起来笨重却能抓住帧同步最隐蔽的分岔来源——比如某次移动设备休眠唤醒后Time.deltaTime异常变大、或者某台机器因为系统弹窗导致主线程卡顿超过 2 秒、帧缓冲被冲毁。联网跑酷这个方向值不值得做我给你的判断是如果只有单人玩法竞争壁垒很薄一旦双人联网跑通体验的社交性和复玩率会明显上一个台阶。前提是你愿意为同步投入一个独立于玩法的工程量。做好赛道清单、帧缓冲、快照恢复三件事这个项目的技术地基就算立住了剩下的大不了是调手感、填内容。希望帮到你。本文还有配套的精品资源点击获取