
简介这份资源面向Unity3D游戏开发学习者与课程设计实践者聚焦在Unity环境中使用C#与Socket实现多人网络通信系统帮助读者理解客户端与服务器之间的数据交互流程。压缩包共约2000个文件整体约29.76MB以bin、info、meta等Unity工程缓存与配置类文件为主同时包含231个cs脚本、40个prefab预制体、31个shader、25个unity场景、22个asset资源及若干dll、xml、txt说明文件构成一套可直接导入研究的完整工程。内容覆盖服务器端监听与连接处理、客户端Connect连接、字节流序列化传输、多线程收发避免阻塞主线程、错误处理机制以及心跳、断线重连、数据压缩等优化思路并涉及玩家状态同步与并发连接等实践问题。已有368人学习适合希望把网络功能整合进Unity场景、提升多人游戏项目开发能力的学习者参考。1. 从一份 Socket 课设包说起Unity 多人通信到底难在哪如果你正在搜「Unity 多人网络通信」「C# Socket 游戏开发」这类词大概率是手里有个课设或者小 Demo 要交或者想给一个单机原型加上联机能力。这份名为「在Unity中运用C#实现基于Socket的多人网络通信系统」的压缩包走的就是最底层的那条路——不依赖 Mirror、Netcode for GameObjects、Photon 这些现成框架直接用System.Net.Sockets里的Socket类从零把服务器和客户端搭起来。它适合两类人一类是游戏开发方向的学生需要一份能跑通、能讲清楚原理的课程设计另一类是想搞明白「联机到底在传什么」的独立开发者用框架用久了反而对底层数据怎么走、线程怎么切没了概念。我拆这类包的习惯是先看它有没有把「连接建立、数据收发、线程调度、异常兜底」这四件事拆开讲。Socket 方案最大的价值不是性能而是透明——每一字节都是你自己序列化、自己发的出问题能定位到具体哪一步。代价也很直接同步、粘包、断线这些框架帮你做的事现在全得自己扛。下面按「先跑通、再拆解、后避坑」的顺序把这份资源里的东西落到能复现的程度。2. 服务器端与客户端把连接建立这一步走通2.1 服务器端的监听与连接接入服务器端的核心就三件事建 Socket、绑端口、开监听。很多人第一次写会卡在Bind的地址参数上IPAddress.Any和IPAddress.Loopback的区别直接决定局域网内别的机器能不能连上。我一般会先把地址和端口抽成常量方便后面改。using System; using System.Net; using System.Net.Sockets; public class GameServer { // 监听端口客户端必须与此一致 private const int Port 9000; private Socket _listenSocket; public void Start() { // 参数依次为IPv4 寻址、流式传输、TCP 协议 _listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // Any 表示监听本机所有网卡局域网联机必须用它 IPEndPoint endPoint new IPEndPoint(IPAddress.Any, Port); _listenSocket.Bind(endPoint); // backlog 是等待队列长度课设场景 10 足够 _listenSocket.Listen(10); Console.WriteLine(服务器已启动等待连接...); // 阻塞式接受连接实际项目应放到独立线程 Socket client _listenSocket.Accept(); Console.WriteLine(客户端已接入 client.RemoteEndPoint); } }这段代码里AddressFamily.InterNetwork对应 IPv4如果你环境里只有 IPv6 会直接抛异常SocketType.Stream配ProtocolType.Tcp是固定搭配换成Dgram就是 UDP语义完全不同。Listen(10)的参数不是「最多连 10 个」而是「已完成三次握手但还没被 Accept 的队列长度」这个点面试常问课设答辩也容易被追问。Accept()是阻塞的主线程调用会卡死所以真实项目里它一定跑在后台线程这一点后面线程那节会展开。2.2 客户端的连接与首包发送客户端比服务器少一步 Bind因为系统会自动分配本地端口。关键在Connect的目标地址本机测试用127.0.0.1局域网联机要填服务器的内网 IP填错就是经典的「连接被拒绝」。using System; using System.Net; using System.Net.Sockets; using System.Text; public class GameClient { private Socket _socket; public void Connect(string serverIp, int port) { _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 连接超时在 Socket 层没有直接参数需用异步或轮询兜底 _socket.Connect(new IPEndPoint(IPAddress.Parse(serverIp), port)); Console.WriteLine(已连接到服务器); // 发送一条握手消息服务端据此确认链路可用 byte[] data Encoding.UTF8.GetBytes(HELLO_SERVER); _socket.Send(data); } }IPAddress.Parse对格式很敏感传localhost会直接抛FormatException得用Dns.GetHostAddresses先解析。Send返回的是实际发出的字节数TCP 下它不保证一次发完你给的全部数据大包场景必须循环发送这是新手最容易忽略的一处。握手消息用 UTF8 编码只是图方便正式协议里应该定一个固定长度的包头否则服务端根本不知道一条消息到哪结束——这就是粘包问题的源头第 4 章会专门拆。2.3 用 Unity 的 MonoBehaviour 驱动网络生命周期课设包里的脚本通常挂在场景物体上用Start启动连接、OnDestroy关闭连接。这里有个 Unity 特有的坑编辑器停止播放时OnDestroy不一定及时触发Socket 没关干净会导致下次运行端口被占用。using UnityEngine; public class NetworkManager : MonoBehaviour { private GameClient _client; void Start() { _client new GameClient(); // 放到线程里避免 Connect 阻塞主线程导致画面卡死 System.Threading.Thread t new System.Threading.Thread(() { _client.Connect(127.0.0.1, 9000); }); t.IsBackground true; // 后台线程主程序退出时自动结束 t.Start(); } void OnApplicationQuit() { // 主动关闭释放端口 _client?.Close(); } }IsBackground true很关键前台线程会阻止进程退出编辑器里表现为「停止播放后卡住」。OnApplicationQuit比OnDestroy更适合做清理它在应用退出前一定会被调用。把Connect丢进线程是权宜之计因为连接失败时线程里的异常不会冒泡到主线程得自己 try-catch 再回传状态否则你只会看到「连不上但没有任何报错」这种玄学现象。3. 数据收发与线程调度让主循环不被网络拖死3.1 字节流与序列化游戏状态怎么变成 byte[]Socket 只认字节所以「玩家位置」「动作指令」这些结构体必须先序列化。课设里最常见的做法是手写BitConverter简单直接但字段一多就容易错位。using System; using System.IO; // 一个玩家状态包id x y 动作枚举 public struct PlayerState { public int PlayerId; public float PosX; public float PosY; public byte Action; public byte[] ToBytes() { using (MemoryStream ms new MemoryStream()) using (BinaryWriter bw new BinaryWriter(ms)) { bw.Write(PlayerId); // 4 字节 bw.Write(PosX); // 4 字节 bw.Write(PosY); // 4 字节 bw.Write(Action); // 1 字节 return ms.ToArray(); // 共 13 字节 } } public static PlayerState FromBytes(byte[] data) { using (MemoryStream ms new MemoryStream(data)) using (BinaryReader br new BinaryReader(ms)) { return new PlayerState { PlayerId br.ReadInt32(), PosX br.ReadSingle(), PosY br.ReadSingle(), Action br.ReadByte() }; } } }BinaryWriter默认小端序收发两端只要都用它就不会有字节序问题但如果服务端是别的语言写的就得确认对方是不是大端。float占 4 字节、int占 4 字节、byte占 1 字节这个包固定 13 字节接收端可以据此判断是否收全。手写序列化的好处是零依赖、可控坏处是字段一改两端都得同步改没有版本兼容。课设规模下够用真上项目建议换 Protobuf 或 MessagePack。3.2 接收线程与主线程的数据交接Unity 的 API 只能在主线程调用所以后台线程收到数据后不能直接改transform.position必须通过一个线程安全的队列转交。using System.Collections.Concurrent; using System.Net.Sockets; using System.Threading; using UnityEngine; public class NetworkManager : MonoBehaviour { // 线程安全队列后台线程写、主线程读 private ConcurrentQueuePlayerState _recvQueue new ConcurrentQueuePlayerState(); private Socket _socket; private Thread _recvThread; void StartRecv() { _recvThread new Thread(() { byte[] buffer new byte[1024]; while (true) { try { int len _socket.Receive(buffer); if (len 0) break; // 对端正常关闭 byte[] real new byte[len]; System.Array.Copy(buffer, real, len); _recvQueue.Enqueue(PlayerState.FromBytes(real)); } catch (SocketException e) { Debug.LogError(接收异常 e.SocketErrorCode); break; } } }); _recvThread.IsBackground true; _recvThread.Start(); } void Update() { // 主线程每帧消费队列安全地更新游戏对象 while (_recvQueue.TryDequeue(out PlayerState state)) { ApplyState(state); } } void ApplyState(PlayerState s) { /* 更新对应玩家物体 */ } }ConcurrentQueue是 .NET 自带的线程安全集合比手写lock更省心。Receive返回 0 表示对端关闭了连接这是判断断线的标准方式别用异常去判断。buffer大小 1024 是课设常用值但要注意它和粘包的关系——一次Receive可能收到半条消息也可能收到两条半直接FromBytes会解析错乱。正确做法是先读包头里的长度字段攒够一条再解析第 4 章会给出完整方案。Update里用while而不是if是为了把一帧内积压的多条消息全部消费掉。3.3 心跳与断线检测TCP 本身有 KeepAlive但默认两小时才探测一次游戏里完全不可用。课设里通常自己发心跳包客户端定时发、服务端超时未收到就判定掉线。using System; using System.Timers; public class Heartbeat { private System.Timers.Timer _timer; private DateTime _lastRecvTime DateTime.Now; private const int TimeoutMs 10000; // 10 秒无数据判定掉线 public void Start(Action onTimeout) { _timer new System.Timers.Timer(3000); // 每 3 秒检查一次 _timer.Elapsed (s, e) { if ((DateTime.Now - _lastRecvTime).TotalMilliseconds TimeoutMs) { onTimeout?.Invoke(); _timer.Stop(); } }; _timer.AutoReset true; _timer.Start(); } // 每收到任何数据都刷新时间戳 public void Refresh() _lastRecvTime DateTime.Now; }心跳间隔和超时阈值要配合间隔 3 秒、超时 10 秒意味着连续丢 3 个包才判定掉线能容忍短暂抖动。System.Timers.Timer的回调在 ThreadPool 线程上执行onTimeout里如果要碰 Unity 对象同样得走队列转主线程。这套机制只能检测「连接还在不在」检测不了「对端卡死但 TCP 还活着」的情况后者需要应用层协议配合课设阶段不用深究。4. 避坑与排查Socket 联机最常见的五个翻车现场4.1 现象客户端连不上报「目标计算机积极拒绝」原因通常是服务器没启动或者端口填错也可能是服务器Bind用了Loopback导致只监听本机。解决顺序是先确认服务器进程在跑再用netstat -an | findstr 9000看端口有没有处于LISTENING最后检查客户端填的 IP 是不是服务器的真实内网地址。防火墙拦截也会表现为连接超时而非拒绝两者要区分开。4.2 现象消息收着收着就错位位置数据变成天文数字这是粘包/拆包的典型症状。原因是你假设「一次 Receive 等于一条完整消息」而 TCP 是字节流没有消息边界。解决办法是自定义包头前 4 字节写消息体长度接收端先读 4 字节拿到长度 N再循环读到攒够 N 字节才解析。下面是一个最小实现。// 发送端长度前缀 消息体 byte[] body state.ToBytes(); byte[] packet new byte[4 body.Length]; BitConverter.GetBytes(body.Length).CopyTo(packet, 0); body.CopyTo(packet, 4); socket.Send(packet); // 接收端先攒够 4 字节读长度再攒够 body 长度 int need 4, bodyLen -1, offset 0; byte[] buf new byte[4096]; // 伪代码逻辑offset 累加达到 need 后切换 need bodyLenBitConverter.GetBytes同样是小端两端一致即可。这个方案能解决 90% 的粘包问题剩下 10% 是消息体超过缓冲区大小需要分多次读逻辑一样只是循环条件更细。4.3 现象编辑器里点停止后卡住或者下次运行提示端口被占用原因是 Socket 没关闭或者后台线程还在跑。Socket实现了IDisposable关闭时要先Shutdown(SocketShutdown.Both)再Close()只调Close有时会残留 TIME_WAIT 状态。线程方面IsBackground true能保证进程退出时线程被强制结束但更稳妥的是设一个volatile bool _running标志循环里检查它退出前置 false。4.4 现象局域网内别的电脑连不上本机却正常九成是服务器Bind用了IPAddress.Loopback即 127.0.0.1它只接受本机连接。改成IPAddress.Any即可监听所有网卡。改完还连不上检查两台机器是不是在同一网段以及服务器所在机器的防火墙有没有放行该端口。课设演示时这一步最容易翻车建议提前在目标环境实测一遍。4.5 现象数据发出去但对方收不到或者收到的是乱码先确认两端编码一致UTF8 对 UTF8、BinaryWriter 对 BinaryReader混用必乱。再确认发送时Send的返回值有没有被忽略——大包场景下Send可能只发了一部分必须循环发剩余字节。最后检查是不是在Update里每帧发大包把带宽打满导致丢包游戏状态同步应该做差量或限频比如每秒 10 到 20 次而不是跟着帧率跑。5. 进阶技巧把课设代码往「能用」推一步课设能跑通只是起点真正让它像个系统我一般会补三件事。第一是消息分发别在接收线程里写一堆if-else判断消息类型用一个字典把消息 ID 映射到处理函数新增协议时只加一行注册不改接收逻辑。using System.Collections.Generic; public class MessageDispatcher { private Dictionaryint, System.Actionbyte[] _handlers new Dictionaryint, System.Actionbyte[](); public void Register(int msgId, System.Actionbyte[] handler) { _handlers[msgId] handler; } public void Dispatch(int msgId, byte[] body) { if (_handlers.TryGetValue(msgId, out var h)) h(body); else UnityEngine.Debug.LogWarning(未注册的消息类型 msgId); } }消息 ID 建议用常量类集中管理别散落在各处写魔法数字。第二是状态同步的限频与插值服务端按固定 tick比如 20Hz广播客户端收到后不要直接赋值而是记录目标位置做插值否则低帧率下角色会瞬移。第三是断线重连客户端检测到Receive返回 0 或抛异常后不要立刻退出而是隔几秒重试Connect重试次数设上限避免死循环。验证这套东西是否真的通了我习惯用两个指标一是开两个客户端一个移动另一个能看到平滑移动而不是跳跃二是拔掉网线再插上客户端能在几秒内自动恢复连接。这两个场景过了课设答辩基本稳。从那以后我每次写完网络模块都会强制走一遍「断网重连 双端同步」的验证不再只看「能连上」就收工。希望帮到你。本文还有配套的精品资源点击获取