
简介这份资源面向Unity3D游戏开发学习者与课程设计实践者聚焦在Unity环境中使用C#与Socket实现多人网络通信系统。内容围绕服务器端监听与连接管理、客户端连接与数据收发、游戏状态序列化传输、多线程处理、错误处理以及心跳与断线重连等优化思路展开适合已具备C#基础、希望深入网络编程的开发者参考。压缩包共约2000个文件以bin、info、meta、cs脚本、prefab预制体、shader、unity场景、dll、xml配置及png贴图等为主其中cs脚本承载核心通信逻辑prefab与unity场景用于搭建可运行示例meta与asset维护资源引用关系整体约29.76MB。目前已有368人学习下载。通过该资源读者可对照完整工程结构理解Socket通信在Unity项目中的落地方式掌握客户端与服务器数据交互、线程调度与异常处理等关键环节为多人在线游戏开发积累可复用的实践思路。1. 从一份被解压的压缩包说起Unity 里那套 Socket 通信到底怎么跑起来很多人第一次拿到“在Unity中运用C#实现基于Socket的多人网络通信系统”这类压缩包时第一反应是双击解压、拖进 Unity、点运行然后发现两个客户端连不上或者连上了但位置不同步。问题不在代码而在于没搞清这套东西的骨架它本质是一个 C# 的 TCP 服务端加一个 Unity 客户端服务端负责维护所有连接和广播状态客户端负责发指令、收状态、做插值。这套方案适合做小型联机 Demo、毕设、局域网对战原型不适合直接上公网做商业级实时对战。下面我按“先跑通、再拆解、后调优”的顺序把这条链路完整讲一遍包括我踩过的坑和最后能稳定跑起来的参数。2. 先让两个客户端连上最小可运行的服务端与 Unity 客户端2.1 服务端为什么用 TcpListener 而不是 Unity 自带的 NetworkServerUnity 早期自带的 NetworkServer 已经淘汰新版本走 Netcode for GameObjects但那个东西对新手来说封装太厚出问题很难排查。用原生System.Net.Sockets里的TcpListener反而更透明你能看到每个字节怎么进来、怎么出去。常见做法是单独建一个 C# 控制台项目跑服务端不跟 Unity 编辑器混在一起这样 Unity 崩溃不会影响服务端进程。服务端最小骨架如下我一般会把它放在一个独立的 .NET 控制台项目里using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; class Program { // 保存所有已连接客户端的 TcpClient用于广播 static ListTcpClient clients new ListTcpClient(); static void Main(string[] args) { // 监听本机所有网卡的 8888 端口局域网内其他机器才能连 TcpListener server new TcpListener(IPAddress.Any, 8888); server.Start(); Console.WriteLine(Server started on port 8888); while (true) { // AcceptTcpClient 会阻塞直到有客户端连进来 TcpClient client server.AcceptTcpClient(); lock (clients) { clients.Add(client); } Console.WriteLine(Client connected: client.Client.RemoteEndPoint); // 每个客户端开一个独立线程收消息避免互相阻塞 Thread t new Thread(() HandleClient(client)); t.IsBackground true; t.Start(); } } static void HandleClient(TcpClient client) { NetworkStream stream client.GetStream(); byte[] buffer new byte[1024]; try { while (true) { int len stream.Read(buffer, 0, buffer.Length); if (len 0) break; // 客户端正常断开 string msg Encoding.UTF8.GetString(buffer, 0, len); Console.WriteLine(Recv: msg); Broadcast(msg, client); // 转发给其他人 } } catch (Exception e) { Console.WriteLine(Client error: e.Message); } finally { lock (clients) { clients.Remove(client); } client.Close(); } } static void Broadcast(string msg, TcpClient exclude) { byte[] data Encoding.UTF8.GetBytes(msg); lock (clients) { foreach (var c in clients) { if (c exclude) continue; try { c.GetStream().Write(data, 0, data.Length); } catch { /* 忽略单个客户端写失败 */ } } } } }这段代码里三个参数最关键IPAddress.Any表示监听所有网卡如果你只写127.0.0.1局域网里另一台机器就连不上端口8888要避开系统占用段我一般用 8000 到 9000 之间buffer大小 1024 是保守值后面讲粘包时会提到为什么不能随便改大。2.2 Unity 客户端连接与收发别在 Update 里直接 ReadUnity 侧新建一个空场景挂一个NetworkClient脚本。新手最容易犯的错是在Update里调stream.Read因为Read是阻塞的会把主线程卡死编辑器直接无响应。正确做法是开一个后台线程专门收收到消息后放进一个线程安全的队列主线程在Update里从队列取。using System.Collections.Concurrent; using System.Net.Sockets; using System.Text; using System.Threading; using UnityEngine; public class NetworkClient : MonoBehaviour { TcpClient client; NetworkStream stream; Thread recvThread; // 线程安全队列收消息线程写主线程读 ConcurrentQueuestring msgQueue new ConcurrentQueuestring(); void Start() { client new TcpClient(); // 局域网内填服务端所在机器的局域网 IP本机测试用 127.0.0.1 client.Connect(192.168.1.100, 8888); stream client.GetStream(); recvThread new Thread(ReceiveLoop); recvThread.IsBackground true; recvThread.Start(); } void ReceiveLoop() { byte[] buffer new byte[1024]; while (true) { try { int len stream.Read(buffer, 0, buffer.Length); if (len 0) break; string msg Encoding.UTF8.GetString(buffer, 0, len); msgQueue.Enqueue(msg); } catch { break; } } } void Update() { while (msgQueue.TryDequeue(out string msg)) { Debug.Log(收到: msg); // 在这里解析消息并更新其他玩家位置 } } public void Send(string msg) { byte[] data Encoding.UTF8.GetBytes(msg); stream.Write(data, 0, data.Length); } void OnApplicationQuit() { recvThread?.Interrupt(); stream?.Close(); client?.Close(); } }ConcurrentQueue是 .NET 自带的线程安全队列比你自己加lock更省心。OnApplicationQuit里必须关连接否则 Unity 编辑器反复运行会残留大量 TIME_WAIT 状态的连接最后端口耗尽连不上。2.3 用一条 JSON 消息验证链路是否真的通了先别急着同步位置用一条最简单的 JSON 验证收发。客户端Start里连上后发一条Send({\type\:\login\,\name\:\player1\});服务端收到后原样广播另一个客户端在Update里打印出来。如果两个客户端都能看到对方的名字说明 TCP 链路、编码、广播逻辑全部正常。这一步过了再动位置同步否则后面出问题你分不清是网络层还是逻辑层。3. 把位置同步做稳消息协议、序列化与插值3.1 自定义二进制协议比 JSON 省一半带宽JSON 可读性好但每条消息带一堆花括号和字段名位置同步每秒发 20 条带宽很快上去。常见做法是自定义一个定长二进制头前 4 字节是消息总长度第 5 字节是消息类型后面跟具体数据。这样服务端和客户端都能先读长度再读内容天然解决粘包。// 打包4字节长度 1字节类型 负载 static byte[] Pack(byte type, byte[] payload) { int total 4 1 payload.Length; byte[] buf new byte[total]; // 小端写入长度BitConverter 默认小端 byte[] lenBytes System.BitConverter.GetBytes(total); System.Buffer.BlockCopy(lenBytes, 0, buf, 0, 4); buf[4] type; System.Buffer.BlockCopy(payload, 0, buf, 5, payload.Length); return buf; }位置负载用 3 个 float 表示 x、y、z共 12 字节加上头 5 字节一条消息 17 字节。JSON 同样内容至少 60 字节以上。BitConverter.GetBytes在小端机器上直接可用跨平台时要注意大小端但 Unity 主流平台都是小端不用过度设计。3.2 服务端只做转发不做权威计算很多教程让服务端算物理客户端只发输入。这套方案里我建议服务端只做转发位置由客户端自己算自己发。原因很简单服务端做权威计算要引入帧同步或状态同步的完整体系代码量翻三倍而小型 Demo 根本不需要防作弊。客户端每帧算完位置后按固定间隔发送float sendTimer 0f; float sendInterval 0.05f; // 20Hz void Update() { sendTimer Time.deltaTime; if (sendTimer sendInterval) { sendTimer 0f; byte[] payload new byte[12]; System.Buffer.BlockCopy(System.BitConverter.GetBytes(transform.position.x), 0, payload, 0, 4); System.Buffer.BlockCopy(System.BitConverter.GetBytes(transform.position.y), 0, payload, 4, 4); System.Buffer.BlockCopy(System.BitConverter.GetBytes(transform.position.z), 0, payload, 8, 4); byte[] packet Pack(1, payload); // 类型1表示位置 stream.Write(packet, 0, packet.Length); } }sendInterval设 0.05 秒是 20Hz局域网下足够流畅。设成 0.016 秒60Hz带宽翻三倍肉眼几乎看不出区别。我一般从 20Hz 起步卡顿再往上调。3.3 接收端插值不插值就像看幻灯片远端玩家位置如果直接赋值20Hz 的数据在 60 帧的屏幕上就是一顿一顿的。标准做法是维护一个目标位置每帧用Vector3.Lerp逼近Vector3 targetPos; float lerpSpeed 10f; void Update() { transform.position Vector3.Lerp(transform.position, targetPos, Time.deltaTime * lerpSpeed); }lerpSpeed取 10 左右比较跟手太小会拖影太大会抖动。收到位置消息时只更新targetPos不要直接改transform.position。这个细节决定了你的 Demo 看起来是“能玩”还是“像半成品”。4. 避坑与排查粘包、断线、端口占用这三件事最耗时间4.1 粘包两次 Send 被合并成一次 Read现象客户端发了两条消息服务端一次Read收到两条拼在一起解析直接崩。原因TCP 是字节流没有消息边界操作系统可能把两次Write合并成一个包发出去。解决用第 3 章说的长度头收的时候先读 4 字节长度再按长度读剩余部分。如果一次Read读到的字节数不够就缓存起来等下次。// 收包缓存处理半包 Listbyte cache new Listbyte(); void OnReceive(byte[] data) { cache.AddRange(data); while (cache.Count 4) { int len System.BitConverter.ToInt32(cache.ToArray(), 0); if (cache.Count len) break; // 半包等下次 byte[] full cache.GetRange(0, len).ToArray(); cache.RemoveRange(0, len); ProcessPacket(full); // 完整包交给业务 } }4.2 断线不清理服务端 clients 列表越跑越大现象客户端直接关窗口服务端Read返回 0 但没进finally或者进了但没从列表移除。原因Read返回 0 表示对端正常关闭但异常关闭会抛异常两条路径都要清理。解决在finally里统一clients.Remove并Close。另外广播时对每个客户端try-catch单个写失败不影响其他人。4.3 端口占用Unity 反复运行后连不上现象编辑器里停止再运行客户端报Connection refused。原因上一次运行的连接没关端口处于 TIME_WAIT。解决OnApplicationQuit和OnDestroy里都调Close服务端TcpListener停止时调Stop。如果还不行换一个端口号别跟系统较劲。4.4 主线程卡死在 Update 里做阻塞操作现象Unity 编辑器点运行后直接无响应任务管理器显示 CPU 占满。原因stream.Read或client.Connect在主线程调用网络不通时一直阻塞。解决连接和接收都放后台线程主线程只处理队列。Connect也可以设超时但简单起见先确保服务端已启动再运行客户端。5. 进阶把 20Hz 位置同步压到 10Hz 还能不卡的三个技巧第一个技巧是只在位置变化超过阈值时才发包。玩家站着不动时每 0.05 秒发一次完全浪费。加一个判断当前位置和上次发送位置距离小于 0.01 就跳过带宽直接降一半。Vector3 lastSentPos; if (Vector3.Distance(transform.position, lastSentPos) 0.01f) { // 发送位置 lastSentPos transform.position; }第二个技巧是远端插值用速度外推。收到位置后不只用Lerp还根据两次位置差算一个速度在两次消息之间按速度继续移动这样即使发包频率降到 10Hz看起来依然连续。实现上维护lastTargetPos和targetPos用(targetPos - lastTargetPos) / interval得到速度每帧transform.position velocity * Time.deltaTime同时用Lerp做轻微修正。第三个技巧是服务端广播时跳过发送者自己。很多新手写的广播把消息也发回给发送者客户端收到自己的位置再插值产生抖动。Broadcast(msg, client)里那个exclude参数就是干这个的别省。验证方法很简单开两个客户端一个用键盘移动另一个观察。如果移动端松手后远端立刻停住不漂移说明阈值和插值参数都对了。如果远端还在滑说明外推速度没清零在收到新位置时把速度重置即可。我自己的习惯是每加一个新功能前先用两个客户端跑一遍纯文本消息确认链路没断再动二进制和插值。这套 Socket 方案上限不高但胜在透明、可控、每一行代码你都知道在干什么。如果你要做的是局域网小规模联机原型它足够如果要上公网、要防作弊、要支持几十人同屏趁早换 Netcode 或专用服务器方案别在这套代码上硬堆。希望帮到你。本文还有配套的精品资源点击获取