
简介这是一套基于ASP.NET与Socket实现的Unity多人联机游戏Demo源码主要面向正在学习Unity网络通信、服务端开发以及多人联机机制的初中级开发者能够帮助读者从零搭建一个可运行的联机项目并理解前后端交互全流程。包内目录划分清晰McProject为Unity工程McServer为VS服务端工程内含已发布版本McClient是客户端发布版同时提供部署好的测试服务端地址为124.223.118.118、端口8888下载后直接运行客户端即可体验联机效果。整个压缩包共包含301个文件以DLL库、C#脚本、Asset资源、Prefab预制体、EXE可执行程序、Config配置文件为主并辅以材质、图片、XML文档等工程与发布产物齐全整体约27.45MB。目前已有407人下载学习适合作为网络通信与多人游戏的综合实践素材。学习源码时可重点关注Socket的连接与消息处理、ASP.NET服务端的部署方式、客户端联机同步逻辑以及多人会话管理等关键实现基于现有框架还可以继续扩展登录验证、房间管理、玩家同步等联机玩法功能。1. 一个zip装下的联机demoASP.NET、socket和Unity三人各司其职很多Unity开发者的第一个联机demo不是死在玩法上而是死在“服务端到底怎么写”这个问题上。单机版跑得再欢一碰socket网络编程问题就变成用什么做服务端、消息怎么编码、Unity主线程跟socket回调线程怎么协调。标题里这个demo源码给的答案是ASP.NET管服务端进程socket管TCP通道Unity只管表现和输入三个角色各司其职。这套组合没有引入Mirror、Photon这类现成联机框架而是用最底层的方式把“客户端A发消息→服务端转发→客户端B收到”这条链路完整走通非常适合想搞清楚联机原理再决定要不要上框架的人也适合课程设计、毕设演示这类需要快速拿出可运行demo的场景。先说一个反直觉的结论这个demo里的ASP.NET其实不是用来处理HTTP请求的它只是借用了ASP.NET Core的进程模型来托管一个TcpListener后台服务。这样做的理由很现实——你不需要单独部署一个控制台程序不需要处理进程守护一个WebApplication跑起来TCP服务和HTTP接口共存于同一个进程日志、配置、生命周期统一管理。下面开始拆这套方案从服务端骨架、协议拆包、Unity接入到避坑和验证按能复现的顺序走。2. ASP.NET服务端骨架TcpListener如何在一个Web进程里常驻2.1 为什么是ASP.NET一个进程里同时跑HTTP和TCP的宿主方案先解决选型疑问。做联机demo的服务端常见的选择有纯控制台程序、.NET的TcpListener裸写、ASP.NET Core托管后台服务再往上就是Kestrel自带的WebSocket。控制台程序的问题在于你要自己管进程重启、日志输出、配置读取做demo还行一旦要加HTTP接口做房间列表或者服务器状态查询就得再起一个Web服务。而ASP.NET Core的IHostedService机制让TCP服务和WebAPI可以共用一个进程——端口可以不同生命周期由宿主统一管理程序退出时能优雅关闭监听。这个方案跟直接用WebSocket的区别也值得说清楚。WebSocket天然跑在HTTP握手之上Unity侧需要额外引用WebSocket库而原生socketTCP直接走System.Net.SocketsUnity的Mono运行时自带支持不需要任何第三方包。对于“基于ASP.NET和socket实现”这个主题使用TcpListener IHostedService是最贴近标题本意的实现路径也最能展示底层原理。2.2 让TcpListener在后台常驻IHostedService的最小实现这里的落地做法是用WebApplication.CreateBuilder建一个最小ASP.NET Core宿主注册一个GameTcpServer后台服务TCP监听与HTTP接口并存。下面给出服务端程序入口// Program.cs using GameServer; var builder WebApplication.CreateBuilder(args); builder.Services.AddHostedServiceGameTcpServer(); // 注册TCP后台服务 var app builder.Build(); app.MapGet(/health, () tcp server is running); // 顺手留一个HTTP探活接口 app.Run();AddHostedService是ASP.NET Core提供的后台任务注册方式GameTcpServer继承BackgroundService后它的ExecuteAsync方法会在应用启动时自动执行应用关闭时收到取消令牌并触发停止逻辑。/health接口不是必须的但建议留着——当你怀疑TCP服务是否还活着的时候直接在浏览器里访问这个地址就能确定进程本身是否正常。然后是核心的TCP服务类// GameTcpServer.cs using System.Net; using System.Net.Sockets; using System.Text; namespace GameServer; public class GameTcpServer : BackgroundService { private readonly TcpListener _listener; private readonly ListTcpClient _clients new(); public GameTcpServer() { // 监听所有网卡的9000端口backlog为128 _listener new TcpListener(IPAddress.Any, 9000); _listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Server.ReceiveTimeout 0; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _listener.Start(128); while (!stoppingToken.IsCancellationRequested) { var tcpClient await _listener.AcceptTcpClientAsync(stoppingToken); _ Task.Run(() HandleClientAsync(tcpClient, stoppingToken)); } } private async Task HandleClientAsync(TcpClient tcpClient, CancellationToken token) { _clients.Add(tcpClient); var buffer new byte[8192]; // 8KB读缓冲 var stream tcpClient.GetStream(); try { while (!token.IsCancellationRequested) { var readCount await stream.ReadAsync(buffer, token); if (readCount 0) break; // 对端关闭连接 // 这里把buffer交给协议解析层处理下一章实现 } } catch (Exception ex) { Console.WriteLine($客户端连接异常: {ex.Message}); } finally { _clients.Remove(tcpClient); tcpClient.Close(); } } }这段代码有几个参数需要解释。IPAddress.Any表示监听服务器所有网卡这样同一局域网内的手机和电脑都能通过服务器IP连上来如果只写127.0.0.1那就只有本机才能连这是个容易踩的细节。端口9000建议避开常见的8080、443也尽量别用1024以下的系统保留端口9000-9100这个区间在联机demo里比较安全。backlog设为128表示等待Accept的排队连接数上限demo几十个客户端足够。ReuseAddress解决的是服务端重启后端口处于TIME_WAIT状态导致绑定失败的问题这点在后面的避坑章里会细说。读缓冲8192字节是常用经验值——太小说一次读不完一帧数据太大浪费内存对demo的网络包来说8KB够用。2.3 收发循环的参数设置端口、pending队列与读缓冲上一段的代码里藏着一个需要单独讲的设计Task.Run(() HandleClientAsync(...))。AcceptTcpClientAsync每接收一个新连接就把这个连接的收发循环扔到线程池去跑。这样做是为了让ExecuteAsync能立刻回到Accept循环继续接收下一个客户端——如果在ExecuteAsync里直接处理客户端收发第二个客户端就永远连不进来。ReceiveTimeout 0表示不设接收超时让读取一直阻塞等待数据。这里有个取舍demo阶段不设超时最省事但生产环境一定要配合心跳机制比如30秒没有收到任何数据就主动断开。心跳的实现在后面第6章会讲到服务端侧只需要在ReadAsync返回0时认定对端断开配合客户端定时发送心跳包就能做到连接的健康管理。另外Task.Run会让异常处理变复杂一点——HandleClientAsync内部自己做了try-catch这是有意的因为线程池任务里的异常不能被外层捕获必须就地处理否则客户端异常断开时整个服务会静默丢失连接。服务端还有一个常见参数值得提Nagle算法。TCP默认开启Nagle会把小的数据包合并后发送这会导致联机游戏的输入指令延迟增加。对于需要低延迟的帧同步或快节奏操作建议在客户端和服务端都关闭它tcpClient.NoDelay true;NoDelay设为true后每个小数据包都会立刻发送不再等合并。对应的代价是网络利用率下降——但一个联机demo的消息量本来就不大延迟优先于带宽。这一行写在AcceptTcpClientAsync之后、进入收发循环之前。3. 定协议、拆粘包联机demo里最决定成败的20行代码3.1 消息格式先定死4字节长度头 JSON体服务端和客户端能通信前提是两端对“一条消息长什么样”有一致的约定。最常见的TCP协议格式是“长度头 消息体”前4字节用整数表示消息体的长度后面跟着的字节就是真正的消息内容。长度头解决了两个问题接收方知道读多少字节算一条完整消息消息体里就算包含特殊字符也不会被误判为消息边界。消息体用JSON还是二进制取决于游戏类型。帧同步或状态同步要求强一致的场景用二进制序列化比如把float、int按固定偏移写入byte[]休闲类、策略类以及demo阶段用JSON最省事Unity侧用JsonUtility服务端用System.Text.Json两边都原生支持不需要引入第三方库。JSON的缺点是体积大、序列化有CPU开销但对demo完全够用。下面定义一个最小消息结构// 消息类型 public enum GameMsgType { Login 1, // 客户端登录携带玩家名 Position 2, // 位置同步 PlayerJoined 3, // 服务端广播新玩家加入 PlayerLeft 4, // 服务端广播玩家离开 Heartbeat 5 // 心跳 } // 服务端和客户端共用的消息基类 [System.Serializable] public class GameMessage { public int msgType; // 对应GameMsgType public string playerId ; public string payload; // 不同消息类型的附加数据 }协议的要点在于字段命名必须跨端一致。Unity的JsonUtility对字段名的大小写敏感msgType在C#里用小写开头JSON里就也是小写服务端System.Text.Json默认不区分大小写但为了不出玄学问题建议两端的字段名完全一致不要一端用MsgType一端用msgType。payload字段是偷懒的手法——每个消息类型的具体数据都塞进这个字符串里虽然丑但改协议时不需要改基类对demo来说扩展性最好。3.2 拆包器用MemoryStream把“半包”攒成“整包”TCP是流式协议没有消息边界。也就是说客户端连续发送的三条消息服务端可能一次性收到全部字节也可能分五次才收完。如果直接按读到的字节数去反序列化必然翻车。拆包的思路是先把收到的字节追加到一个待处理缓冲区然后循环从缓冲区里判断是否够一个“长度头”够就取出长度、判断缓冲区是否已有完整消息体有则取走不够就等下一次读取。// PacketBuffer.cs using System.Text; public class PacketBuffer { private readonly MemoryStream _stream new(); private readonly byte[] _lengthBytes new byte[4]; public void Append(byte[] data, int count) { _stream.Write(data, 0, count); } public Listbyte[] TryParseAll() { var messages new Listbyte[](); _stream.Position 0; while (_stream.Length - _stream.Position 4) { _stream.Read(_lengthBytes, 0, 4); int bodyLength BitConverter.ToInt32(_lengthBytes, 0); if (_stream.Length - _stream.Position bodyLength) { // 数据不够一个完整消息体回到长度头之前的位置等待更多数据 _stream.Position - 4; break; } var body new byte[bodyLength]; _stream.Read(body, 0, bodyLength); messages.Add(body); } // 把剩余未处理的数据移到缓冲区开头 var remaining _stream.Length - _stream.Position; var leftover new byte[remaining]; _stream.Read(leftover, 0, (int)remaining); _stream.SetLength(0); _stream.Write(leftover, 0, leftover.Length); return messages; } }这个拆包器是全网socket编程里最经典的一段代码逻辑说明拆成三步。第一步Append把网络流读到的原始字节追加到MemoryStream这里的关键是count参数——ReadAsync返回的字节数不一定等于缓冲数组长度直接用数组长度会把上次遗留的数据污染进去。第二步TryParseAll进入循环先读4字节长度头再判断缓冲区内是否已有完整消息体这里用了_stream.Position - 4回退指针的技巧本质是把“半包”留在缓冲区里等下次Append后继续。第三步把未处理完的残留数据搬到缓冲区头部这是为了下一次TryParseAll时Position 0能正确从头开始。这里有一个参数容易被忽略bodyLength的上限校验。恶意客户端或网络故障可能导致声明长度异常大比如几十MB导致内存暴涨。demo阶段最少也要加一个限制if (bodyLength 0 || bodyLength 65536) throw new Exception($非法消息长度: {bodyLength});64KB是TCP消息体比较合理的上限超过这个值就应该考虑分片发送而不是靠扩大缓冲区硬扛。3.3 消息分发登录、广播、离开三个动作有了拆包器服务端收到原始字节后就能还原成GameMessage再根据msgType做分发。一个最小的消息分发逻辑长这样private async Task DispatchAsync(TcpClient sender, byte[] messageBytes) { var json Encoding.UTF8.GetString(messageBytes); var msg JsonSerializer.DeserializeGameMessage(json); switch (msg.msgType) { case (int)GameMsgType.Login: // 把连接和玩家ID绑定广播新玩家加入 Broadcast(${msg.playerId} joined the game); break; case (int)GameMsgType.Position: // 把位置消息转发给其他客户端 BroadcastToOthers(sender, messageBytes); break; case (int)GameMsgType.Heartbeat: // 收到心跳说明客户端还活着不回包也行 break; } } private void BroadcastToOthers(TcpClient sender, byte[] messageBytes) { foreach (var client in _clients) { if (client sender || !client.Connected) continue; var stream client.GetStream(); stream.Write(messageBytes, 0, messageBytes.Length); } }BroadcastToOthers和Broadcast的区别在第二个参数——前者是转发原始字节后者是构造一个自动消息再全员发送。转发原始字节的好处是不需要重新序列化减少CPU开销但前提是每个消息都带有接收方需要的信息比如来源玩家ID。这个设计直接决定了客户端能不能在自己的界面上区分“这是我自己的消息”和“这是别人的消息”——建议服务端在转发时统一加一个serverTime字段客户端可以拿它算延迟。消息分发这里还有一个容易被忽略的线程安全问题。第2章的HandleClientAsync每个连接跑在一个线程池任务里多个玩家同时发位置消息时_clients列表会同时被多个线程操作。ListT的并发读写会让Count属性错乱出现诡异的不定时异常。解决方法是加锁或者改用ConcurrentDictionary最简单的做法private readonly object _lockObj new(); // 在Add/Remove/遍历_clients的所有位置都加上 lock(_lockObj) { ... }这个锁的粒度虽然粗但demo的消息量远达不到锁竞争成为瓶颈的程度图省事的代价完全可接受。4. Unity客户端接入socket从BeginReceive回调到主线程派发4.1 Unity侧用BeginReceive回调收消息为什么不能直接开一个while线程Unity客户端和服务器的最大区别在于线程模型。服务端可以随意用await ReadAsync阻塞在后台线程上但Unity的MonoBehaviour生命周期和所有GameObject操作都必须在主线程执行。socket回调天然跑在线程池线程上如果直接在回调里改transform.position在编辑器下偶尔能跑通打包到真机上大概率闪退或者出现渲染卡死这是Unity联机开发最典型的翻车点。Unity侧收消息的常见做法是使用Socket.BeginReceive回调模式而不是开一个while(true)线程去阻塞读。原因有两个第一Unity的Mono运行时对线程的创建和管理不如原生平台高效频繁的线程上下文切换会挤占渲染线程的时间第二BeginReceive的异步回调由.NET线程池调度配合消息派发队列可以做到收消息和主线程更新互不阻塞。// UnitySocketClient.cs using System; using System.Net.Sockets; using System.Text; using UnityEngine; public class UnitySocketClient : MonoBehaviour { private Socket _socket; private readonly byte[] _recvBuffer new byte[8192]; private readonly PacketBuffer _packetBuffer new PacketBuffer(); public void ConnectToServer(string ip, int port) { _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socket.NoDelay true; _socket.BeginConnect(ip, port, OnConnectCallback, null); } private void OnConnectCallback(IAsyncResult ar) { _socket.EndConnect(ar); _socket.BeginReceive(_recvBuffer, 0, _recvBuffer.Length, SocketFlags.None, OnReceiveCallback, null); } private void OnReceiveCallback(IAsyncResult ar) { int readCount _socket.EndReceive(ar); if (readCount 0) { _packetBuffer.Append(_recvBuffer, readCount); var messages _packetBuffer.TryParseAll(); foreach (var msg in messages) { // 不在这里直接处理消息入队交给主线程派发 MainThreadDispatcher.Enqueue(msg); } _socket.BeginReceive(_recvBuffer, 0, _recvBuffer.Length, SocketFlags.None, OnReceiveCallback, null); } else { // 对端关闭连接 Debug.LogWarning(服务器断开连接); } } }这段代码里最容易被新手忽略的是BeginReceive的连续性——每次回调处理完数据后必须再调一次BeginReceive否则收完一次数据后就再也不会触发了。_recvBuffer是同一个字节数组反复传给BeginReceive存在一个风险如果上一次回调还没处理完数据就触发了下一次回调同一个缓冲区会被覆盖。解决方法是这里用PacketBuffer.Append立即把数据复制走而不是等主线程派发时再读因为主线程派发有延迟缓冲区早被覆盖了。4.2 跨线程派发回调线程到主线程的队列桥Unity主线程派发器是每个Unity网络项目都要写一次的公共组件。原理很简单socket回调线程往一个队列里塞消息主线程的Update每帧把队列里的消息取出来依次处理。这个模式绕开了C#的线程锁问题——利用ConcurrentQueue保证入队出队的线程安全。// MainThreadDispatcher.cs using System.Collections.Concurrent; using System.Collections.Generic; using UnityEngine; public class MainThreadDispatcher : MonoBehaviour { private static readonly ConcurrentQueuebyte[] _messageQueue new(); public static void Enqueue(byte[] message) { _messageQueue.Enqueue(message); } private void Update() { while (_messageQueue.TryDequeue(out byte[] message)) { HandleMessage(message); } } private void HandleMessage(byte[] message) { // 转成GameMessage后switch分发 // 只有在这里才能安全地操作Transform、Rigidbody等 } }这里有一个Unity特有的坑Update的调用间隔是帧率相关的如果游戏帧率掉到10帧消息处理也跟着变慢联机延迟会陡增。更好的方案是改成在FixedUpdate里处理网络消息因为FixedUpdate是固定时间间隔调用不随帧率波动。但FixedUpdate的默认间隔是0.02秒50Hz如果服务器消息频率超过50Hz队列积压会越来越严重。我的建议是demo阶段直接用Update因为帧率本身稳定在60帧队列积压的概率极低等发现消息处理成为瓶颈时再考虑改FixedUpdate或者把处理逻辑移到子线程只把最终结果派发回主线程。派发队列还要注意内存问题如果服务端持续发送大量消息而主线程处理不过来ConcurrentQueue会无限膨胀。最简单的保护是设定队列上限超过上限就丢弃最旧的消息游戏场景里优先保证最新状态而不是所有历史消息都执行。private static readonly ConcurrentQueuebyte[] _messageQueue new(); private const int MaxQueueSize 256; public static void Enqueue(byte[] message) { if (_messageQueue.Count MaxQueueSize) { _messageQueue.TryDequeue(out _); // 丢最旧 } _messageQueue.Enqueue(message); }4.3 位置同步参数发送频率、插值窗口与Time.timeScale的关系多人联机demo的核心玩法是看到其他玩家的位置变化。位置同步有两个参数决定体验发送频率每次发送间隔和接收端插值方式。发送频率方面我的实践是位移用10-15Hz旋转用20Hz分开两个通道发。为什么不是每帧都发因为每帧发送会占用大量网络带宽和服务端转发CPU而且Unity的Update帧率不稳定会造成位置更新抖动。在FixedUpdate里发送更合适——固定时间间隔发送频率就等于1 / Time.fixedDeltaTime默认约50Hz。如果不希望发送那么频繁可以加一个时间门槛private float _lastSendTime; private void FixedUpdate() { // 每0.1秒发送一次位置10Hz用Time.fixedDeltaTime累积 if (Time.time - _lastSendTime 0.1f) return; _lastSendTime Time.time; var msg new GameMessage { msgType (int)GameMsgType.Position, playerId _myPlayerId, payload ${transform.position.x},{transform.position.y},{transform.position.z} }; SendMessage(msg); }接收端插值同样关键——如果收到别人的位置就直接赋值对方的位置会一跳一跳的因为网络包到达时间不均匀。常见做法是维护一个目标位置队列Update时用Vector3.Lerp把显示位置平滑过渡到目标位置。插值速度参数要调太快看起来像瞬移太慢看起来像橡皮糖。我的经验值是Time.deltaTime * 10也就是大约0.1秒追平一个身位的距离demo里手感比较自然。这里必须提醒一个跟Time.timeScale相关的坑游戏里如果做了暂停菜单Time.timeScale 0会让Update里的Time.deltaTime变成0插值也跟着卡住但FixedUpdate会停止调用导致位置发送也断掉。多人联机游戏里暂停是非常危险的动作——所有客户端的位置都会冻结但玩家还能移动视角。处理方案是使用Time.unscaledDeltaTime计算发送间隔和插值让网络逻辑不受Time.timeScale影响。5. 联机demo避坑端口冲突、回调线程与真机回环地址5.1 端口绑定失败address already in use的三种来历现象服务端第二次启动时抛异常提示“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”进程直接退出。原因第一种是服务端程序刚关闭端口还处于TIME_WAIT状态需要等待约120秒才能重新绑定Windows上默认第二种是你上一次运行的服务端进程没有真正退出比如IDE里没停掉就直接重新启动第三种是另一个程序正好占用了这个端口。这三种情况里第二种最常见不是代码的问题是进程管理的问题。解决先看进程是否残留命令行执行netstat -ano | findstr 9000最后一列是PID任务管理器里找到对应进程杀掉。确认没有残留后代码层面的方案是启动前设置ReuseAddress这不会绕过正常的TIME_WAIT限制但能避免同端口快速重启时的绑定失败。如果实在排不干净换一个端口是最快的后悔药——9001、9002随便挑一个改一行配置的事。5.2 回调里异常被catch后连接再也用不了现象Unity客户端在socket回调里写了try-catch捕获异常后打了一条日志然后下次再往这个socket发数据发现什么都发不出去也没有任何报错。原因TCP socket一旦进入异常状态比如对端重置连接、网络超时这个连接就废了。catch只能捕获异常不能让socket起死回生更隐蔽的是Socket对象在抛出异常后其内部状态已经标记为错误后续即使调用Send不报错数据也不会到达对方。解决在catch里做“连接标记为已断开 触发重连流程”而不是尝试继续使用同一个socket。具体做法是增加一个_isConnected布尔字段任何收发异常都把它置为false并调用事件通知游戏逻辑层。重连时重新new Socket、重新BeginConnect而不是复用旧对象。这是socket编程里最典型的一个“看起来没坏其实全坏了”的陷阱。5.3 手机上连不上127.0.0.1回环地址与局域网IP的坑现象Unity编辑器里输入127.0.0.1:9000能连上服务端打包到手机上运行后在手机上也填127.0.0.1连接失败。原因127.0.0.1是设备自身的回环地址手机上填它表示连手机自己而服务端跑在电脑上必须填电脑在局域网里的IP。这是联机demo最常见的第一次真机体验翻车点。解决手机和电脑连接同一个WiFi电脑上执行ipconfigWindows或ifconfigmacOS/Linux查无线网卡的IPv4地址比如192.168.1.100在手机上填这个IP。另外Android客户端还必须在AndroidManifest.xml里声明网络权限uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE /缺了INTERNET权限socket连接会静默失败或者抛Permission denied而且Unity打包时不会自动帮你加上这两个权限。5.4 Unity进入Play Mode后socket卡死的线程问题现象编辑器里跑Unity客户端第一次进入Play Mode能正常连接停止Play Mode再重新进入发现socket连不上或者主线程卡死。原因MonoBehaviour在Play Mode退出时被销毁但socket回调所在的线程还在运行它尝试往已销毁的对象上调用方法比如往一个已经释放的ConcurrentQueue入队或者操作一个被销毁的GameObject导致异常或死锁。Unity的播放模式停止不等于线程停止——socket线程是.NET线程池的不归Unity管理。解决在OnApplicationQuit和OnDestroy里做完整的socket关闭逻辑——先标志停止接收然后Shutdown(SocketShutdown.Both)最后Close()。注意顺序不能反Close之前必须Shutdown否则已挂起的BeginReceive回调可能无法退出造成socket对象泄漏。另外在主线程派发器里加入是否仍处于Play Mode的判断退出时清空队列并拒绝新消息。private void OnDestroy() { _socket?.Shutdown(SocketShutdown.Both); _socket?.Close(); _socket null; }6. 验证联机没写错双开、打点与断线重连的最小做法验证联机demo最直接的手段是同一台电脑上双开客户端。Unity编辑器支持开多个实例进入Play Mode后菜单Edit里勾选Game View的多个窗口、或者手动复制工程目录后用另一个编辑器实例打开。实际操作中用“编辑器 打包后的exe”组合最省事——编辑器里跑一个客户端方便看日志和调试打包exe跑另一个客户端模拟真实环境两者同时连服务端就能验证位置同步和消息广播是否正确。第二件必做的事是给消息处理打时间点。在客户端收到消息的派发入口和服务端转发的出口各打一行Debug.Log内容带上消息类型和当前Time.realtimeSinceStartup。用realtimeSinceStartup而不是Time.time的原因是后者受Time.timeScale影响暂停游戏时会造成日志时间线混乱。对比两边的日志时间戳如果差值稳定在几十毫秒内说明网络链路通畅如果差值忽大忽小优先查Nagle是否关闭、服务端是否有锁竞争。最后把断线重连做成一个最小模块这是demo步入可玩状态的分水岭。做法是客户端每秒检查一次_isConnected断开时进入重连状态每2秒尝试重新连接一次连上后重新发送登录消息并且本地维护一个GameState.version整数重连后向服务端索要最新状态服务端把版本号比客户端大的消息重放一遍。这个机制不复杂但能让demo从“一断就完蛋”进化到“断了能回来继续玩”。我自己的习惯是demo阶段就把Nagle、重连、超时这三个东西配好因为它们不是功能需求等上线后缺了任何一个都得返工。这些参数和代码都不是玄学每一行都对应一次实际的翻车经历照着这条路走至少能把踩坑次数砍掉一半。希望帮到你。本文还有配套的精品资源点击获取