简介:这份资源面向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 ConcurrentQueue<PlayerState> _recvQueue = new ConcurrentQueue<PlayerState>(); 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 Dictionary<int, System.Action<byte[]>> _handlers = new Dictionary<int, System.Action<byte[]>>(); public void Register(int msgId, System.Action<byte[]> 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,重试次数设上限避免死循环。
验证这套东西是否真的通了,我习惯用两个指标:一是开两个客户端,一个移动另一个能看到平滑移动而不是跳跃;二是拔掉网线再插上,客户端能在几秒内自动恢复连接。这两个场景过了,课设答辩基本稳。从那以后我每次写完网络模块,都会强制走一遍「断网重连 + 双端同步」的验证,不再只看「能连上」就收工。希望帮到你。
本文还有配套的精品资源,点击获取