简介:一份基于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 自带 UNET(UnityEngine.Networking,2018 版后被标记为过时);更常见的是社区维护的 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 / Client | UIManager、NetworkDiscovery |
| Room | 双人准备、房主确认开始 | RoomPlayer、GameStartHandler |
| Game | 跑酷主玩法、得分与同步 | PlayerController、ObstacleManager、GameSync |
读的时候留意一点:Room 场景不一定独立存在,很多紧凑工程会把「创建房间」按钮直接接到NetworkManager.StartHost()上,然后加载 Game 场景。这个写法不是问题,只要保证角色是网络层统一 Spawn 出来的,而不是场景里预先摆好的空物体。否则会出现一个典型症状:本地画面一切正常,对面屏幕根本看不见你——原因往往是角色没走网络 Spawn,每个客户端自己在场景里 new 了一个。第 5 章会专门展开这个坑。
2.3 为什么固定跑道比自由 3D 移动更适合联机
画面表现上同样是跑酷,实现方式分成两派:一派是角色在固定三车道之间左右切换,另一派是自由奔跑加转向。这套源码选用的是前者,我建议你保持这个选择。原因是联机状态下,固定跑道把「位置同步」降维成了「车道索引 + 里程 + 朝向」这类整数级状态,即使网络抖动,最多是切换延迟半秒,不会出现角色位置来回抖。自由移动虽然手感上限高,但双人模式下每个客户端的 Position 每秒会同步几十次,一旦网络波动,对方角色就像在瞬移,体验反而更差。
固定跑道的核心逻辑通常集中在两个脚本里:PlayerController 负责响应输入并更改 laneIndex,LaneSwitcher 负责平滑移动角色。常见调参如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| laneWidth | 2.0 ~ 3.0 | 三车道间距,太窄容易误判相邻障碍 |
| switchSpeed | 8 ~ 12 | 切道动画速度,低于 6 手感黏滞 |
| gravity | -20 ~ -30 | 跳跃下落感,跑酷类不建议用默认 -9.8 |
| jumpForce | 7 ~ 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 管理、事件同步、场景切换——都有现成代码可以对着抄,剩下就是把自己的手感参数一点点试进去。希望帮到你。
本文还有配套的精品资源,点击获取