
简介一份基于Unity3D的双人联网跑酷游戏完整工程面向游戏开发初学者、进阶学习者也可用作毕业设计、课程设计或工程实训项目。项目围绕双人联机对战与跑酷玩法展开涵盖场景搭建、角色控制、网络同步、UI交互等核心模块可直接运行学习或二次开发。压缩包共2005个文件主体为346个prefab预制体、353个fbx模型、1007个meta资源映射文件以及48个材质球、38个C#脚本、4个动画控制器与2个anim动画剪辑附带DLL插件、XML配置和Unity工程设置整体约211.11MB目录按资源类型组织便于按需查找替换。内容预览中可见CoinAnimation、UIRotation等动画资源覆盖金币旋转、界面旋转等细节表现。目前已有280人学习下载适合据此快速上手双人联网游戏开发流程掌握联机同步、角色动画和关卡设计的落地实现。1. 拿到这套 Unity3D 双人联网跑酷游戏源码先把预期放对老实说第一次打开这套 Unity3D 双人联网跑酷游戏源码时我没抱太大希望——单机跑酷的教学一抓一大把真正卡住大多数人的是「两个人怎么连进同一局」那段。把工程导入、场景跑起来之后发现它把菜单、房间、双人同步和结算都串完了局域网里一台开房另一台加入两个角色同时起跑、互相看得见位置变化撞同一组障碍各自扣血。对正在做毕业设计、或者想把手里的单机 Demo 扩成联机小游戏的人来说这份源码正好补上从「能跑」到「能联」之间最缺的工程代码。这篇拆解按「架构 → 玩法 → 同步 → 避坑 → 验证」的顺序讲新手能照着走熟手可以直接看参数和踩坑记录。2. 选型与架构网络层用哪套 API场景怎么组织为什么固定跑道更适合联机2.1 网络层选型UNET 还是 Mirror先看清用的是哪套 API拆源码第一步先看 NetworkManager 继承自什么。这套工程存在两种常见情况老派写法用 Unity 自带 UNETUnityEngine.Networking2018 版后被标记为过时更常见的是社区维护的 Mirror从 UNET 分支出来API 几乎沿用。判断方法很简单看脚本里的 using 语句// 方式一UNET 遗留写法 using UnityEngine.Networking; public class PlayerNetwork : NetworkBehaviour { } // 方式二Mirror 写法社区更推荐 using Mirror; public class PlayerNetwork : NetworkBehaviour { }如果源码用的是 UNET我的建议是别急着整体重写先把功能跑通确认没有用到早已被移除的内部接口再说。如果是 Mirror后续加功能、扩房间逻辑都会省心很多社区文档和示例都比 UNET 全。参数上重点检查两处一是 NetworkManager 里注册的 Spawnable Prefabs 列表角色预制体和掉落物预制体是否都加进去了二是 Transport 类型默认 kcp 适合局域网和小规模公网联机换成 TCP 跨网络稳定性略好但延迟会高一点双人跑酷这种高速移动场景不建议换。2.2 场景与预制体结构从菜单到双人房间的最小闭环联机游戏和单机的最大结构区别在于「流程状态」。单机可以只有一个 Game 场景联机至少要拆出 Menu、Room或依赖 NetworkManager 自动匹配和 Game 三段。这套工程常见的组织方式如下表我建议按这个顺序去读代码不要一头扎进 Game 场景里乱翻。场景/目录职责需要重点看的脚本Menu玩家输入昵称、点击 Host / ClientUIManager、NetworkDiscoveryRoom双人准备、房主确认开始RoomPlayer、GameStartHandlerGame跑酷主玩法、得分与同步PlayerController、ObstacleManager、GameSync读的时候留意一点Room 场景不一定独立存在很多紧凑工程会把「创建房间」按钮直接接到NetworkManager.StartHost()上然后加载 Game 场景。这个写法不是问题只要保证角色是网络层统一 Spawn 出来的而不是场景里预先摆好的空物体。否则会出现一个典型症状本地画面一切正常对面屏幕根本看不见你——原因往往是角色没走网络 Spawn每个客户端自己在场景里 new 了一个。第 5 章会专门展开这个坑。2.3 为什么固定跑道比自由 3D 移动更适合联机画面表现上同样是跑酷实现方式分成两派一派是角色在固定三车道之间左右切换另一派是自由奔跑加转向。这套源码选用的是前者我建议你保持这个选择。原因是联机状态下固定跑道把「位置同步」降维成了「车道索引 里程 朝向」这类整数级状态即使网络抖动最多是切换延迟半秒不会出现角色位置来回抖。自由移动虽然手感上限高但双人模式下每个客户端的 Position 每秒会同步几十次一旦网络波动对方角色就像在瞬移体验反而更差。固定跑道的核心逻辑通常集中在两个脚本里PlayerController 负责响应输入并更改 laneIndexLaneSwitcher 负责平滑移动角色。常见调参如下参数推荐值说明laneWidth2.0 ~ 3.0三车道间距太窄容易误判相邻障碍switchSpeed8 ~ 12切道动画速度低于 6 手感黏滞gravity-20 ~ -30跳跃下落感跑酷类不建议用默认 -9.8jumpForce7 ~ 9配合 gravity能跳过 2~3 层障碍这套组合我在拆包后调过几轮laneWidth 不建议低于 2低于 2 时两个角色擦肩而过视觉上像穿模switchSpeed 太高则碰撞判定跟不上。这个度属于手感玄学但固定跑道的碰撞判定写起来比自由移动简单不少——只需要判断当前 lane 上有没有障碍不用做完整包围盒求交。3. 把跑酷玩法剥开角色控制、障碍生成与碰撞判定3.1 自动奔跑与三跑道切换角色控制脚本的四个关键参数跑酷玩法的起点是「自动前进」。这里有一个常见误判很多新手把角色前移写进 Update()直接transform.Translate然后叠加Time.deltaTime。单机没问题网络模式下这样写会让每个客户端各自维护一套里程数据最后两个人跑出来的「终点里程」对不上结算时判断谁先到就没有意义了。更稳的做法是客户端只上报输入左移、右移、跳里程统一由服务器或房主侧的 GameManager 计算再广播给两个客户端。代码骨架如下// PlayerController.cs客户端输入 上报 using UnityEngine; using Mirror; public class PlayerController : NetworkBehaviour { public float laneWidth 2.5f; public float switchSpeed 10f; public int currentLane 1; // 0 左1 中2 右 [Command] public void CmdSwitchLane(int direction) { // 服务器权威只有服务器能改车道 currentLane Mathf.Clamp(currentLane direction, 0, 2); TargetRpcLaneChanged(currentLane); } void Update() { if (!isOwned) return; // 不控制别人的角色 if (Input.GetKeyDown(KeyCode.A)) CmdSwitchLane(-1); if (Input.GetKeyDown(KeyCode.D)) CmdSwitchLane(1); if (Input.GetKeyDown(KeyCode.Space)) CmdJump(); } }这段代码里三个地方值得注意isOwned是 Mirror 里判断「这个角色是不是本端玩家控制的」的关键不加这条你会发现自己按 A/D 键把对面玩家的角色也切了车道CmdSwitchLane用服务器权威方式修改车道索引能避免两个客户端状态不一致TargetRpcLaneChanged是通知本端玩家「可以开始播放切道动画了」。参数 laneWidth 和 switchSpeed 前面已说过切道本身建议用 Dotween 或协程实现直接赋坐标没有过渡动画观感很差。跳跃的写法类似但跳跃有个隐藏问题跳跃高度和重力如果放在客户端各自算两个人跳过同一个障碍与否会不一致。我一般会把跳跃的起始时间点上报服务器障碍碰撞判定用「服务器时间 固定跳跃曲线」来模拟不依赖每个客户端的物理帧。固定跳跃曲线指写死一组高度数据比如 0.1 秒时高 0.5、0.2 秒时高 0.9 这样判定结果在两端完全可复现。3.2 障碍生成与对象池一段可复现的生成逻辑障碍生成有两种常见方案一种是固定随机种子每个客户端用相同种子各自生成跑起来表现一致另一种是服务器只发「障碍出现事件」由客户端本地生成。第一种的问题在于一旦有客户端中途加入种子生成顺序已经错位后续就全不一致了所以更推荐第二种。下面是服务端下发障碍事件的简写// ObstacleSpawner.cs服务器端负责生成并广播 using UnityEngine; using Mirror; public class ObstacleSpawner : NetworkBehaviour { public GameObject obstaclePrefab; public float spawnInterval 2.2f; public override void OnStartServer() { InvokeRepeating(nameof(SpawnLoop), 1f, spawnInterval); } [Server] void SpawnLoop() { int lane Random.Range(0, 3); GameObject go Instantiate(obstaclePrefab, LanePosition(lane), Quaternion.identity); NetworkServer.Spawn(go); // 让所有客户端都生成这个物体 } Vector3 LanePosition(int lane) { return new Vector3((lane - 1) * 2.5f, 0f, 60f); } }NetworkServer.Spawn是联机模式下生成同步物体的核心调用。注意这里只生成了一个障碍物实际工程里得换成对象池否则每 2.2 秒一个 GameObject跑三分钟就是八十多个实例移动端直接发热降频。对象池的常见做法是服务器端预创建 N 个待用物体激活时移动到目标 lane用NetworkServer.Spawn通知客户端回收时NetworkServer.UnSpawn再放回池子。N 的取值建议按「关卡时长 / spawnInterval」向上取整再加 10 个余量比如 3 分钟关卡每 2.2 秒一个大约 82 个池子做到 100 足够。碰撞判定方面跑酷类不要用OnTriggerEnter一把梭碰到即死这种一刀切逻辑体验很差。更细的做法是区分三种碰撞蹭到边缘减速、正面撞上掉血、跳过头顶无伤。实现上给障碍物加 BoxCollider 并调整 z 轴长度让碰撞体比视觉模型窄 0.3 个单位玩家体验会明显变好——很多玩家抱怨「明明躲开了还判定撞上」就是碰撞体比模型宽的典型症状。3.3 得分与金币何时需要服务器权威何时交给本地跑酷游戏里最容易同步出错的点是金币和计分。我的推荐是两个策略分开用金币被吃到的瞬间客户端立刻播放音效和粒子让手感有即时反馈同时把这枚金币的 ID 发一条 Command 给服务器由服务器确认「这枚金币没被对方先吃掉」再广播给两端扣掉这枚金币。优点是玩家不会因为网络延迟而觉得「吃了没反应」不必等服务器回包才播动画。局域网里不明显公网一测就知道这种反馈的差距有多大。常见的错误做法是把金币数量做成一个全局 SyncVar每次捡金币都让所有人刷新计数。看起来简单但金币是高频小事件两个玩家在同一帧吃到两枚金币时计数就会互相覆盖最终总数对不上。正确做法是金币 ID 集合存在服务器端的 HashSet 里广播采用逐枚扣除方式计数只在 UI 端本地相加。4. 双人联网同步NetworkBehaviour、RPC 与状态同步的落地写法4.1 NetworkManager 配置连接流程与角色 Spawn 点联机项目的第一步不是写玩法而是把 NetworkManager 配置对。标准做法是在 Menu 场景放一个 NetworkManager把 PlayerPrefab 拖进 Spawnable Prefabs 列表再在代码里决定按 Host 还是 Client 启动。双人模式推荐直接用「一个做 Host一个做 Client」的拓扑不要引入独立专用服务器。原因有两点第一跑酷双人玩法数据量小Host 兼任玩家完全够用第二独立服务器涉及部署和托管环境对学习项目不划算。Host 和 Client 的启动代码放在 UI 按钮回调里// NetworkRoomManager.cs简写 using UnityEngine; using Mirror; public class NetworkRoomManager : NetworkManager { public void OnClickHost() { StartHost(); // 本机既当服务器又当玩家 } public void OnClickJoin(string ip) { networkAddress ip; // 输入框填对方 IPv4例如 192.168.1.23 StartClient(); } }networkAddress是局域网联机中最关键的参数。同一个局域网下房主把本机 IPv4 地址告诉对方就能连上如果两台电脑不在同一网段需要在路由器上做端口映射Mirror 默认监听 7777 UDP。测试阶段还有一个更常见的坑Windows 防火墙默认拦截 UDP 入站经常出现「对方能看到房间但点加入永远转圈」十有八九是防火墙拦了对应端口手动放行 Unity 和 7777 端口就好。4.2 RPC 与 SyncVar谁跑得快、谁撞了墙状态怎么同步Mirror 体系里有两种数据同步手段SyncVar 适合低频、需要持续保持一致的字段RPC 适合一次性事件、不需要回放的状态。在这套跑酷源码里典型的分配方式是数据类型同步手段原因双人里程SyncVar持续变化双方需要随时一致车道索引SyncVar TargetRpc变化频率低但需要通知播放动画吃到金币事件ClientRpc 广播一次性事件不需要保留状态撞障掉血ClientRpc事件型不可重放最终排名SyncVar游戏结束时要展示写 SyncVar 有一个容易翻车的点它只能在服务器端修改客户端直接写编译不报错运行也看似正常但同步出去的一直是旧值。下面这段是正确写法// PlayerProgress.cs服务器权威数值同步 using UnityEngine; using Mirror; public class PlayerProgress : NetworkBehaviour { [SyncVar(hook nameof(OnDistanceChanged))] public float distance; [Server] public void AddDistance(float amount) { distance amount; // 只有服务器能改 } void OnDistanceChanged(float oldValue, float newValue) { // 本地 UI 更新两个客户端都会执行 UIManager.instance.SetDistanceText(newValue.ToString(F1)); } }hook 回调是个容易被忽略的好东西当 distance 变化时Mirror 自动调用OnDistanceChanged并传新旧值。注意 UI 更新处不要做任何逻辑判定因为客户端执行 hook 的时间点未必是事件发生的真实时刻在这里触发胜负判断会造成两个客户端判定结果不一致。4.3 断线处理与重连一局结束后的清理顺序双人联机的收尾比单人复杂得多。一局跑完常见需求是「回到房间再来一局」但如果没有做好清理最常见的现象是——第二局开始时第一局的角色和障碍物还残留在场景里两个玩家各带一个「幽灵角色」在跑。原因是NetworkServer.Spawn出来的物体场景 reload 时没有被 UnSpawn会残留在线程连接上。正确的清理顺序是服务器先广播 GameOver 事件客户端停止输入和移动逻辑服务器调用NetworkServer.UnSpawn回收所有动态生成的障碍和金币切回 Room 场景前调用ServerChangeScene而不是客户端各自 LoadScene玩家角色保留等待下一局 NetworkManager 重新索引。最容易被忽略的是第 3 步。Mirror 模式下场景切换必须由服务器发起并同步给客户端客户端自己 LoadScene 会导致「房子已切但网络连接不知道」后面出现连接假死。如果是局域网双人断线重连需求不强但至少要保证一方掉线时另一方不会卡死在等待界面。常见兜底是每 5 秒做一次心跳检测超时没响应就主动回到主菜单并提示「对方已离开」。5. 双人联机避坑Spawn 不一致、端口冲突与角色瞬移的 5 条踩坑记录5.1 对方看不到我的角色现象两个人都能进同一个房间但各自屏幕上只有自己的角色对方视角里是空的。原因角色没有走网络 Spawn 流程。最常见的是在场景里预先放了 Player 预制体或者 Start 方法里自己 Instantiate而不是由 NetworkManager 在玩家连接时自动生成。这样每个客户端只生成了本地角色服务器不知道该物体属于哪个连接自然不广播给对方。解决把 Player 预制体从场景中移掉放进 NetworkManager 的 Spawnable Prefabs 列表并确保脚本继承 NetworkBehaviour。自检方法很简单在角色脚本 Awake 里打一条Debug.Log(netId)如果两个客户端显示不一致就说明根本不是同一个物体。5.2 同机双开测试端口冲突现象同一台电脑上开两个 Unity 编辑器实例第二个实例加入时提示连接失败或者加入后立刻掉线。原因Mirror 默认监听 7777 UDP两个实例同时抢同一个端口后启动的自然失败。另外编辑器本身跑两套工程调试端口和资源加载也会冲突。解决给其中一个实例指定不同端口。更省事的做法是先 Build 一个客户端 exe再用编辑器连它。两个进程一个走编译后运行一个走编辑器调试模式端口冲突概率小很多。如果执意双开编辑器需要手动修改 KcpTransport 的 Port 字段NetworkManager 本身没有直接暴露端口属性这点别找错地方。5.3 金币吃了但对方看不到消失现象我吃到金币自己这边金币消失、音效播放正常但对方屏幕上那枚金币还在。原因金币的回收只在本地执行了 Destroy没有同步到服务器和其他客户端。只要没调用NetworkServer.Destroy服务器就认为物体仍然存活其它客户端的表现也全部保留。解决金币回收必须走服务器权威。下面这个写法可用// Coin.cs using UnityEngine; using Mirror; public class Coin : NetworkBehaviour { [Client] void OnTriggerEnter(Collider other) { if (!other.CompareTag(Player)) return; CmdCollectCoin(netId); // 把金币的 netId 发到服务器 } [Command] void CmdCollectCoin(uint id) { NetworkServer.Destroy(gameObject); } }Command 方法里不要再做本地视觉反馈视觉反馈留给OnTriggerEnter自己的音频播放就行。这样两端收的 Destroy 通知是同一帧不会出现一边吃掉另一边没反应。5.4 切道之后位置漂移现象本地按 A/D 切道很跟手但对方屏幕上自己的角色是慢慢划过去甚至在两个车道之间来回抖。原因位置是通过 SyncVar 同步的 Vector3而 SyncVar 默认只在数值变化时发送发送频率受 Network Send Rate 限制Mirror 默认 30 次/秒。跑酷角色每帧都在移动位置数据量大且连续SyncVar 的差值压缩在这种场景下表现很差于是抖动。解决两条路径一是把 Transform 同步组件换成 NetworkTransform 并开启插值二是降低位置同步频率把移动权交给服务器端表现。跑酷这种高速移动场景我更倾向后者客户端只管输入位置模拟由服务器统一计算每隔 50ms 广播一次客户端收到后在两帧之间做 Lerp。牺牲的是表现延迟换来的是确定性。5.5 一局结束回到房间卡在加载界面现象跑完一局点「再来一局」两个客户端都显示加载中永远进不去房间。原因场景切换不是服务器发起的。某个客户端自己用了SceneManager.LoadScene服务器场景切走了但网络连接状态还停留在旧场景句柄上玩家角色身份信息丢失加载完成事件一直不被触发。解决强制使用 Mirror 的ServerChangeScene两端确认场景加载完成后再推进。写 NetworkBehaviour 回调时注意保留基类实现再追加逻辑很多人 override 后忘了调用 base日志里会一直报「Scene not ready」警告。排查顺序永远是先看服务器端日志有没有确认加载完成再看客户端有没有重复调用加载。6. 把这套源码跑成一局完整游戏验证流程与参数调优拿到工程第一件事别急着改逻辑先把「能不能完整跑通一局」验证掉。我的固定流程如下每一步都盯一个明确指标先做一次 Windows Build分别打出 Host 版和 Client 版两个包不要用编辑器双开做联机测试只开 Host 版确认菜单出现、StartHost 生效、场景能切到 Game再开 Client 版输入 Host 的 IPv4 地址确认能连上、角色各就各位两个角色跑到终点或生命值耗尽确认结算 UI 弹出、双方显示一致回房间再来一局确认没有残留物体、没有卡加载界面。验证过程中我最常调的参数集中在 NetworkManager 和角色控制器两处。KcpTransport 的 Send Rate 保持 30 就够不用追 60对方位置平滑插值的 Interpolation Delay 我给 0.05 秒比默认 0.1 更跟手laneWidth 按 2.5 起步换成自制模型时用模型宽度除以 2 再加 0.3 的碰撞余量就是该设的数值。GameManager 里的初始倒计时双人模式建议设 3 秒1 秒的话玩家还没把手放上键盘就开跑了。想换角色模型的话从 SolidWorks 这类 DCC 工具导出 FBX 时注意两个地方一是单位设置Unity 默认 1 单位 1 米在外部工具用英寸或厘米导出导入后会缩成蚂蚁大小二是 Animator 状态机里的出口动作名要和代码里 Trigger 参数名严格一致差一个字母切道动画就播不出来。导入后把 CharacterController 的 Radius 调到和模型脚部宽度近似Height 匹配模型高度不要用模型自带的 Mesh Collider性能差且判定粗糙。最后提一个实例第一次把这份源码调完给朋友联机测试对方一直喊「你老是穿模」最后定位不是碰撞体问题而是障碍物 z 轴判定的起始距离设到了 0.5角色已经在 0.55 的距离上完成切道视觉躲开了但判定还没过去。把 z 轴判定起点改到 0.3碰撞体 z 长度缩短到模型厚度的 80%从那以后我每次调跑酷碰撞都先看这组参数不再当运气处理。这套源码的可贵之处在于双人联机最容易翻车的几个环节——Spawn 管理、事件同步、场景切换——都有现成代码可以对着抄剩下就是把自己的手感参数一点点试进去。希望帮到你。本文还有配套的精品资源点击获取