如果你现在打开一份 Unity 游戏开发岗位的面试简历,会发现不少候选人写着“熟悉 TCP/IP”“了解 Socket 网络编程”。但面试官追问一句“你用 Socket 做过什么完整项目”,很多人只能答出“写过回显 Demo”。更尴尬的是,那个 Demo 在现场跑不起来,既没有服务端日志,也没有第二台客户端,完全没法证明你真的理解网络通信。
这个问题的根源在于:大多数求职者只做了单机项目,网络编程只停留在概念层面。真正的区分度在于,你有没有一个能完整跑通的、包含服务端和客户端的多人联机项目。对一个面向 27 届求职实习的候选人来说,Unity + C# Socket 做 TCP 多人联机合作生存游戏,是一个性价比很高的选择。
这篇文章会从零开始拆解这个项目的完整链路:网络拓扑怎么选、服务端怎么设计、消息协议怎么定、粘包拆包怎么写、Unity 客户端怎么接入,以及最终如何作为面试作品展示。读完你可以照着搭出一个最小可玩的双人合作生存游戏,并且能应对面试官关于 TCP、Socket、C# 并发和游戏同步的大部分追问。
1. 这个项目真正的价值在哪里
先说结论:这个项目最大的价值不是游戏玩法,而是它覆盖了游戏研发中最难自学的“网络层”。
一个普通单机项目能展示的是 C# 基础、Unity 操作、UGUI、简单 AI。而多人联机项目会让你接触四块硬核内容:
- 传输层协议的现实应用:TCP 为什么可靠、哪里会粘包、为什么需要心跳。
- C# 异步与并发:BeginAccept、BeginReceive、多线程回调、共享数据保护。
- 架构设计:客户端与服务端如何分层、消息如何路由、房间如何管理。
- 工程排错能力:联调失败时怎么定位是网络问题、协议问题还是业务逻辑问题。
这四块内容恰好是游戏客户端、游戏服务端、后端开发、甚至是普通 C# 工程师面试都会考察的方向。用一个项目同时覆盖四条技术线,在实习简历里非常占优势。
另一个现实原因是可演示性。面试官不喜欢只能看截图的项目,但多人联机项目天然可以现场演示:你打开服务端控制台,再开两个 Unity 客户端,一台移动,另一台实时跟随。这种效果比任何项目描述都有说服力。
当然,这个项目也有不适合的场景。如果你投的是纯渲染、TA、图形学方向,花大量时间做网络层意义不大。但如果你投的是游戏客户端、游戏服务器、通用服务端开发,这个项目方向完全值得投入。
2. TCP 多人联机的基础架构与关键决策
2.1 网络拓扑:客户端-服务端模型
多人联机有两种常见拓扑:
- P2P:客户端之间直接通信,没有中心节点。
- 客户端-服务端:所有客户端连接同一个服务端,服务端做权威判断和消息转发。
对这个项目,我建议直接采用客户端-服务端模型,原因有三:
- 反作弊更简单:服务端拥有最终决定权,例如玩家是否扣血、资源是否刷新。
- 消息转发逻辑清晰:客户端只需要维护一条到服务端的连接。
- 面试容易展开:服务端模型的难点更集中,能和面试官聊“权威服务器”的设计理念。
2.2 为什么选 TCP 而不是 UDP
游戏联机经常被拿来对比 TCP 和 UDP。很多人以为 TCP 只适合弱网、UDP 只适合 FPS,这是一个常见的误解。
TCP 的特点是面向连接、可靠传输、保证顺序。它的缺点是存在头部阻塞和重传延迟,极端情况下延迟会比 UDP 高很多。UDP 则是无连接、不可靠、不保证顺序,但延迟低、开销小。
对于合作生存游戏,玩家需要采集资源、建造、对抗怪物,这些操作对可靠性要求远高于实时性。哪怕一个位置包偶尔延迟几十毫秒,也不会有明显体验问题;但如果资源状态丢包了,玩家采集了一个服务端根本没认可的“假资源”,体验会更差。
所以这个项目选 TCP 是完全合理的,面试时你能说出“为什么我的游戏选 TCP 不选 UDP”,就已经比大多数候选人强了。
| 对比项 | TCP | UDP |
|---|---|---|
| 连接状态 | 面向连接 | 无连接 |
| 可靠性 | 可靠、有序 | 不可靠、可能乱序 |
| 传输效率 | 稍低 | 较高 |
| 适合场景 | 资源同步、房间操作、消息可靠 | 实时位置、语音、FPS 射击 |
| 本项目定位 | 主协议 | 可扩展用于高频位置同步 |
2.3 同步方式:状态同步还是帧同步
状态同步和帧同步是游戏联机里绕不开的问题。
状态同步指客户端把“我的位置、血量、动作状态”发给服务端,服务端广播给其他客户端。帧同步则是所有客户端只同步“操作指令”,每帧在本地执行相同逻辑,保证结果一致。
帧同步适合格斗、RTS 这类对操作确定性要求极高的游戏,但它的难点是浮点运算一致性、回放和反外挂都很难。状态同步实现简单、调试方便,也更贴近大多数商业游戏的做法。合作生存游戏采用状态同步是更稳妥的选择。
这个技术选型也更好面试:你可以明确说出“我选择状态同步,因为开发效率高、逻辑容易验证,后续需要时可以局部替换为混合同步”。
3. 环境准备与项目结构
服务端使用 C# 控制台项目,客户端使用 Unity 工程,两者共用一套消息定义,但物理上分成两个工程。这样的好处是服务端逻辑不依赖 UnityEngine,可以直接在 Visual Studio / Rider 中启动调试,不打开 Unity 也能跑。
环境准备大致如下,版本请以实际安装为准:
- Unity 2022.3 LTS 或 Unity 6,模板选择 3D Core。
- .NET SDK 6.0 或更高版本,用于创建服务端控制台项目。
- IDE:Visual Studio、JetBrains Rider,或者 VS Code。
- 平台:Windows / macOS 皆可,局域网联调建议两台设备,没有两台设备也能在同一台机器开两个客户端进程。
项目目录建议按下面这样组织:
CoopSurvival/ ├── Server/ # 服务端控制台工程 │ ├── Program.cs │ ├── NetServer.cs │ ├── ClientConnection.cs │ ├── PacketParser.cs │ ├── MessageDefine.cs │ └── RoomManager.cs └── UnityClient/ # Unity 工程 ├── Assets/ │ ├── Scripts/ │ │ ├── Net/ │ │ │ ├── NetClient.cs │ │ │ ├── PacketParser.cs │ │ │ └── MessageDefine.cs │ │ └── Game/ │ │ ├── PlayerController.cs │ │ ├── PlayerSync.cs │ │ └── UIManager.cs │ └── Scenes/ └── ProjectSettings/服务端和客户端各保留一份 MessageDefine.cs,虽然存在重复,但好处是服务端不依赖 Unity 程序集,可以独立启动和调试。如果你后期工程变大,可以再把公共代码抽成一个 .NET Standard 类库。
4. 服务端核心:C# Socket 异步架构
4.1 为什么用异步 Socket
C# Socket 编程有三种常见方式:同步阻塞、异步回调、async/await。这个项目建议使用异步回调(BeginAccept、BeginReceive)。
同步阻塞模式不适合网络服务器,因为一个客户端的读阻塞会卡住整个线程。异步回调则不占用额外线程,接收由系统 IO 完成端口驱动,适合高并发连接。
不建议一上来就用 async/await 的另一个原因是:异步回调的时序关系更直观,能让你真正理解 “收到数据 -> 拆包 -> 处理 -> 继续接收” 的回环结构。面试官如果追问底层原理,你也可以讲得更清楚。
4.2 服务端启动与监听
先写一个最简服务端启动类:
// 文件路径:Server/NetServer.cs using System; using System.Net; using System.Net.Sockets; public class NetServer { private Socket _listenSocket; private bool _running; public void Start(int port) { _listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listenSocket.Bind(new IPEndPoint(IPAddress.Any, port)); _listenSocket.Listen(100); _running = true; Console.WriteLine($"[Server] listening on 0.0.0.0:{port}"); _listenSocket.BeginAccept(AcceptCallback, null); } private void AcceptCallback(IAsyncResult ar) { if (!_running) return; try { Socket clientSocket = _listenSocket.EndAccept(ar); Console.WriteLine($"[Server] client connected: {clientSocket.RemoteEndPoint}"); ClientConnection connection = new ClientConnection(clientSocket); ClientManager.Instance.Add(connection); connection.StartReceive(); } catch (Exception ex) { Console.WriteLine($"[Server] accept exception: {ex}"); } finally { _listenSocket.BeginAccept(AcceptCallback, null); } } public void Stop() { _running = false; _listenSocket.Close(); } }这里有一个关键细节:在 AcceptCallback 的 finally 里重新调用 BeginAccept。这个回环很重要,如果漏掉,服务端只能接收第一个连接,之后就无响应了。
4.3 连接对象与数据接收
每个客户端连接封装为一个 ClientConnection。它负责接收数据、解析粘包、心跳检测和发送消息。
// 文件路径:Server/ClientConnection.cs using System; using System.Net.Sockets; public class ClientConnection { private Socket _socket; private byte[] _receiveBuffer = new byte[4096]; private PacketParser _parser = new PacketParser(); public DateTime LastHeartbeatTime { get; private set; } = DateTime.UtcNow; public int PlayerId { get; set; } public int RoomId { get; set; } = -1; public ClientConnection(Socket socket) { _socket = socket; } public void StartReceive() { try { _socket.BeginReceive(_receiveBuffer, 0, _receiveBuffer.Length, SocketFlags.None, ReceiveCallback, null); } catch (Exception ex) { Console.WriteLine($"[Server] begin receive error: {ex}"); Close(); } } private void ReceiveCallback(IAsyncResult ar) { try { int length = _socket.EndReceive(ar); if (length <= 0) { Close(); return; } _parser.Append(_receiveBuffer, 0, length, OnPacket); StartReceive(); } catch (Exception ex) { Console.WriteLine($"[Server] receive error: {ex}"); Close(); } } private void OnPacket(byte[] message) { // 在这里解析消息并路由到房间、登录等业务逻辑 LastHeartbeatTime = DateTime.UtcNow; MessageRouter.Instance.Dispatch(this, message); } public void Send(byte[] data) { try { _socket.Send(data); } catch (Exception ex) { Console.WriteLine($"[Server] send error: {ex}"); Close(); } } public void CheckHeartbeat() { double idleSeconds = (DateTime.UtcNow - LastHeartbeatTime).TotalSeconds; if (idleSeconds > 30) { Console.WriteLine($"[Server] client {_socket.RemoteEndPoint} heartbeat timeout, close."); Close(); } } public void Close() { if (_socket != null) { try { _socket.Shutdown(SocketShutdown.Both); } catch { } _socket.Close(); } ClientManager.Instance.Remove(this); Console.WriteLine($"[Server] client closed, current count: {ClientManager.Instance.Count}"); } }这里的 Send 使用的是同步发送。项目演示阶段消息频率不高,这样做没有问题。如果后续要支撑更高并发,应该把 Send 改成异步或者用发送队列加独立发送线程。
心跳检测需要配合一个定时器。在服务端 Program.cs 里,可以启动一个 Timer,每 5 秒遍历一次客户端连接:
// 文件路径:Server/Program.cs using System; using System.Threading; class Program { static void Main(string[] args) { int port = 8888; NetServer server = new NetServer(); server.Start(port); Timer heartbeatTimer = new Timer(state => { ClientManager.Instance.CheckAllHeartbeat(); }, null, 5000, 5000); Console.WriteLine("Press Enter to stop server..."); Console.ReadLine(); server.Stop(); } }到这里,服务端已经有了监听、接收、拆包回调、心跳检查这几个基础能力。你会发现服务端本身没有写任何“游戏玩法”,这是正确的。网络层和业务层应该分开,后续扩展房间和资源点逻辑时才不会把代码堆成一团。
5. 消息协议设计与粘包拆包实现
5.1 协议格式:长度头 + 消息体
网络传输的是一串字节流,TCP 不保证一次接收恰好是一个完整消息。所以通信双方必须约定消息边界。
本项目采用最简单的长度前缀法:
| 4 字节消息长度 | 消息体 | 消息体内部: | 2 字节 msgId | 业务数据 |用 BitConverter 在二进制中,消息长度和 msgId 都采用小端序。C# 在小端平台上是默认小端,你在 Windows、Linux、macOS 上跑都没问题。后面如果涉及跨语言通信,需要统一约定字节序并显式处理。
5.2 消息定义
建议把消息号单独放到一个文件,方便客户端和服务端共用。
// 文件路径:Server/MessageDefine.cs public static class MsgId { public const ushort Heartbeat = 0; public const ushort LoginRequest = 1; public const ushort LoginResponse = 2; public const ushort CreateRoom = 3; public const ushort JoinRoom = 4; public const ushort RoomEntered = 5; public const ushort PositionSync = 6; public const ushort LeaveRoom = 7; }位置同步消息体定义为:
| 字段 | 类型 | 大小 |
|---|---|---|
| PlayerId | int | 4 字节 |
| X | float | 4 字节 |
| Y | float | 4 字节 |
| Z | float | 4 字节 |
5.3 发送封装
封装一个 MessageBuilder,把业务数据转成外层包。
// 文件路径:Server/MessageBuilder.cs using System; using System.IO; public static class MessageBuilder { /// <summary> /// 构造带 msgId 的消息体 /// </summary> public static byte[] BuildBody(ushort msgId, byte[] payload) { using (MemoryStream ms = new MemoryStream()) using (BinaryWriter writer = new BinaryWriter(ms)) { writer.Write(msgId); writer.Write(payload ?? Array.Empty<byte>()); return ms.ToArray(); } } /// <summary> /// 把消息体封装成完整网络包:4 字节长度 + 消息体 /// </summary> public static byte[] EncodePacket(byte[] body) { byte[] packet = new byte[4 + body.Length]; byte[] head = BitConverter.GetBytes(body.Length); Buffer.BlockCopy(head, 0, packet, 0, 4); Buffer.BlockCopy(body, 0, packet, 4, body.Length); return packet; } public static void Send(ClientConnection conn, ushort msgId, byte[] payload) { byte[] body = BuildBody(msgId, payload); conn.Send(EncodePacket(body)); } }5.4 粘包拆包器
粘包是指多个消息被合并到一个 TCP 段里,拆包是指一个消息被拆成多个段。为了处理这两种情况,需要一个缓冲数组。
// 文件路径:Server/PacketParser.cs using System; public class PacketParser { private byte[] _cache = new byte[4096]; private int _cacheLength; /// <summary> /// 追加新收到的数据,解析出完整消息后回调 /// </summary> public void Append(byte[] data, int start, int count, Action<byte[]> onPacket) { if (count <= 0) return; if (_cacheLength + count > _cache.Length) { Resize(_cacheLength + count); } Buffer.BlockCopy(data, start, _cache, _cacheLength, count); _cacheLength += count; int offset = 0; while (_cacheLength - offset >= 4) { int bodyLength = BitConverter.ToInt32(_cache, offset); if (bodyLength < 0 || bodyLength > 1024 * 1024) { throw new Exception($"invalid body length: {bodyLength}"); } if (_cacheLength - offset - 4 < bodyLength) { break; // 数据不完整,等下一批 } byte[] body = new byte[bodyLength]; Buffer.BlockCopy(_cache, offset + 4, body, 0, bodyLength); onPacket(body); offset += 4 + bodyLength; } if (offset > 0) { int rest = _cacheLength - offset; Buffer.BlockCopy(_cache, offset, _cache, 0, rest); _cacheLength = rest; } } private void Resize(int required) { int newSize = _cache.Length * 2; while (newSize < required) newSize *= 2; byte[] newCache = new byte[newSize]; Buffer.BlockCopy(_cache, 0, newCache, 0, _cacheLength); _cache = newCache; } }这个拆包器有四个关键细节:
- 每次新增数据后,优先检查缓存区是否够用。
- 消息长度要加合法性校验,防止恶意客户端发送超大长度导致内存异常。
- 只有确认缓存中有一个完整消息时才回调,否则等待下一次 Receive 填充。
- 解析完成后,把未处理的数据移到数组头部,避免缓慢积累导致数组越界。
服务端和客户端可以复用同一个拆包器代码。如果想让代码更加工程化,可以把这部分抽成公共类库。
6. Unity 客户端接入 Socket
6.1 NetClient 封装
Unity 端的网络封装要解决一个核心问题:Socket 回调运行在系统 IO 线程,而 Unity 的 API 只能在主线程调用。如果直接在回调里操作 GameObject、Transform、UI,会产生随机报错或者卡死。
我的处理方式是:网络回调只负责解析包,然后把消息体放入一个线程安全队列,Unity 主线程在 Update 里取出并处理。
// 文件路径:UnityClient/Assets/Scripts/Net/NetClient.cs using System; using System.Collections.Concurrent; using System.IO; using System.Net.Sockets; using UnityEngine; public class NetClient : MonoBehaviour { public static NetClient Instance { get; private set; } private TcpClient _tcpClient; private NetworkStream _stream; private PacketParser _parser = new PacketParser(); private byte[] _receiveBuffer = new byte[4096]; private ConcurrentQueue<byte[]> _messageQueue = new ConcurrentQueue<byte[]>(); private void Awake() { Instance = this; } private void Update() { while (_messageQueue.TryDequeue(out byte[] body)) { HandleMessage(body); } } public void Connect(string ip, int port) { _tcpClient = new TcpClient(); _tcpClient.BeginConnect(ip, port, ar => { try { _tcpClient.EndConnect(ar); _stream = _tcpClient.GetStream(); _stream.BeginRead(_receiveBuffer, 0, _receiveBuffer.Length, ReadCallback, null); Debug.Log($"[NetClient] connected to {ip}:{port}"); } catch (Exception ex) { Debug.LogError($"[NetClient] connect failed: {ex.Message}"); } }, null); } private void ReadCallback(IAsyncResult ar) { try { int length = _stream.EndRead(ar); if (length <= 0) { Debug.Log("[NetClient] disconnected by server"); return; } _parser.Append(_receiveBuffer, 0, length, body => { _messageQueue.Enqueue(body); }); _stream.BeginRead(_receiveBuffer, 0, _receiveBuffer.Length, ReadCallback, null); } catch (Exception ex) { Debug.LogError($"[NetClient] read error: {ex.Message}"); } } public void Send(ushort msgId, byte[] payload) { if (_stream == null) return; byte[] body = MessageBuilder.BuildBody(msgId, payload); byte[] packet = MessageBuilder.EncodePacket(body); _stream.Write(packet, 0, packet.Length); } private void HandleMessage(byte[] body) { using (MemoryStream ms = new MemoryStream(body)) using (BinaryReader reader = new BinaryReader(ms)) { ushort msgId = reader.ReadUInt16(); byte[] payload = reader.ReadBytes((int)(ms.Length - ms.Position)); GameMessageDispatcher.Instance.Dispatch(msgId, payload); } } private void OnDestroy() { _tcpClient?.Close(); } }这里有一个值得注意的设计:Update 里把消息转发给 GameMessageDispatcher,再由它调用当前场景里的玩家同步逻辑。这样做的好处是网络模块不依赖具体游戏对象,换场景时 NetClient 可以常驻,而具体消息处理器可以按场景注册。
6.2 玩家移动与位置同步
Unity 场景中放置一个地面,两个 Cube 代表玩家。本地玩家用 WASD 控制移动,服务端会把位置同步给同房间其他客户端。
// 文件路径:UnityClient/Assets/Scripts/Game/PlayerController.cs using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed = 5f; void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 dir = new Vector3(h, 0, v); if (dir.magnitude > 0.01f) { transform.position += dir.normalized * (moveSpeed * Time.deltaTime); } } }// 文件路径:UnityClient/Assets/Scripts/Game/PlayerSync.cs using System.IO; using UnityEngine; public class PlayerSync : MonoBehaviour { public int playerId; private float _sendTimer; private const float SyncInterval = 0.1f; // 10 次/秒 void Update() { _sendTimer += Time.deltaTime; if (_sendTimer >= SyncInterval) { _sendTimer = 0f; SendPosition(); } } private void SendPosition() { Vector3 pos = transform.position; using (MemoryStream ms = new MemoryStream()) using (BinaryWriter writer = new BinaryWriter(ms)) { writer.Write(playerId); writer.Write(pos.x); writer.Write(pos.y); writer.Write(pos.z); NetClient.Instance.Send(MsgId.PositionSync, ms.ToArray()); } } /// <summary> /// 由 GameMessageDispatcher 调用,更新远端玩家位置 /// </summary> public void ApplyRemotePosition(byte[] payload) { using (MemoryStream ms = new MemoryStream(payload)) using (BinaryReader reader = new BinaryReader(ms)) { int remoteId = reader.ReadInt32(); float x = reader.ReadSingle(); float y = reader.ReadSingle(); float z = reader.ReadSingle(); if (remoteId == playerId) return; // 自己的位置由本地控制 transform.position = new Vector3(x, y, z); } } }在 GameMessageDispatcher 里,收到 PositionSync 消息后调用对应玩家对象的 ApplyRemotePosition。这样本地玩家移动依靠 WASD,远端玩家位置依靠网络包,整个联机同步链路就通了。
7. 房间管理与合作玩法扩展
7.1 房间管理思路
多人合作游戏需要一个简单的房间系统。服务端可以用一个 RoomManager 管理房间,每个房间维护一个玩家连接列表。
// 文件路径:Server/RoomManager.cs using System.Collections.Generic; public class Room { public int RoomId { get; set; } public string RoomName { get; set; } public List<ClientConnection> Players { get; } = new List<ClientConnection>(); public void Broadcast(byte[] packet, ClientConnection except = null) { foreach (var player in Players) { if (player == except) continue; player.Send(packet); } } } public class RoomManager { private Dictionary<int, Room> _rooms = new Dictionary<int, Room>(); private int _nextRoomId = 1; public Room CreateRoom(string roomName) { Room room = new Room { RoomId = _nextRoomId++, RoomName = roomName }; _rooms[room.RoomId] = room; return room; } public Room FindRoom(int roomId) { _rooms.TryGetValue(roomId, out Room room); return room; } }在实际项目中,你可以在此基础上扩展“加入房间后进入同一场景”“房间满员提示”“离开房间通知”等逻辑。但注意不要把房间逻辑堆在 ClientConnection 里,保持单一职责会让项目更容易维护。
7.2 合作生存玩法落地建议
完成了位置同步和房间系统后,合作玩法可以按下面的顺序逐步加入:
- 资源点同步:服务端维护资源点列表,玩家采集时发送请求,服务端扣除资源并广播。
- 怪物刷新:服务端定时生成怪物,把怪物的类型、位置、血量广播给房间内玩家。
- 玩家状态同步:血量、饥饿值等数值变化通过消息同步,不在客户端本地直接修改。
- 胜负条件:比如合作建造基地、抵御指定波次怪物,服务端记录关卡状态。
这些玩法本身不需要新增网络知识,重点是继续沿用“客户端发起请求、服务端做逻辑判断、再广播结果”这个流程。
8. 联调运行与效果验证
8.1 运行步骤
第一步,启动服务端。在控制台项目目录执行:
dotnet run看到类似输出代表启动成功:
[Server] listening on 0.0.0.0:8888第二步,在 Unity 中打开客户端场景,设置 NetClient 的 IP 和端口。如果是本机联调,IP 填127.0.0.1;如果是局域网联调,填服务端机器的局域网 IP,例如192.168.1.100。
第三步,运行第一个 Unity 客户端,连接后自动创建房间。
第四步,在 Unity Editor 中点击 Play 运行第二个客户端,或者打包一个独立 Build 再运行,连接后加入同一房间。
第五步,控制第一个客户端移动,观察第二个客户端中对应玩家对象是否跟随移动。同时观察服务端控制台,能看到连接建立、心跳正常、玩家加入房间等信息。
8.2 如何判断成功
- 服务端显示两个客户端连接,连接数不为 -1。
- 两个客户端能进入同一个 RoomId。
- 客户端 A 移动,客户端 B 能看到一个 Cube 同步移动。
- 定期输出心跳日志,没有“heartbeat timeout”断开记录。
- 手动关掉一个客户端,服务端能在 30 秒内识别断线并清理连接。
如果其中任何一步不符合预期,优先检查服务端日志和 Unity Console 窗口,大部分问题在日志中能看到明确异常。
9. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 服务端 Bind 时提示端口被占用 | 上次服务端进程未退出 | 在命令行执行netstat -ano查看端口占用 | 杀掉占用进程或更换端口 |
| 客户端提示 connect failed | IP 写错、服务端没启动、防火墙拦截 | 在客户端机器执行ping 服务端IP,再用telnet IP 端口测试 | 核对 IP,确认服务端启动,放行防火墙端口 |
| 服务端只能接收第一个连接 | AcceptCallback 里没有重新 BeginAccept | 查看服务端代码是否在 finally 里接续监听 | 在 finally 中重新调用 BeginAccept |
| 收到的位置乱跳、角色瞬移 | 位置同步频率过高或解析字段顺序错误 | 打印服务端收到的坐标日志 | 核对二进制读写顺序,统一字段顺序 |
| 客户端网络回调里操作 UI 报错 | 跨线程访问 Unity API | 查看报错堆栈是否来自网络线程 | 使用 ConcurrentQueue 切换主线程处理 |
| 心跳正常但仍然断线 | 心跳包没有正确携带 msgId | 抓包或打印心跳消息内容 | 确认客户端心跳发送携带 MsgId.Heartbeat |
| 消息体长度解析异常 | 发送端打出长度头和消息体不一致 | 打印发送端和接收端长度前缀 | 统一 EncodePacket 逻辑,封装到公共类 |
| 手机或第二台电脑连不上 | 服务端监听 127.0.0.1 而不是 0.0.0.0 | 查看服务端监听地址日志 | Bind 使用 IPAddress.Any,即 0.0.0.0 |
10. 简历怎么写与面试高频追问
10.1 简历项目描述参考
不要只写“完成了多人联机游戏”,要突出能验证的技术点:
项目:TCP 多人联机合作生存游戏 技术栈:Unity / C# / Socket / TCP / 二进制协议 职责与成果: - 独立实现基于异步 Socket 的 TCP 游戏服务端,支持多客户端并发连接、心跳检测、断线清理。 - 设计长度头消息协议,解决粘包拆包问题,客户端与服务端共用同一套协议定义。 - 采用状态同步方案,实现玩家位置实时同步与多人房间管理。 - 将网络回调解耦到主线程消息队列,避免 Unity 跨线程操作 UI/GameObject 引起的异常。这里每个点都是面试官能顺着往下问的,写上去的每一句都必须能展开讲。
10.2 面试官大概率会问的问题
- TCP 三次握手和四次挥手的过程是怎样的?
- TCP 为什么会有粘包?怎么解决?
- 为什么你的位置同步用 10 次/秒,这个频率怎么定的?
- 状态同步和帧同步的区别?你的项目为什么选状态同步?
- 为什么用异步 Socket 而不是多线程阻塞?
- 多个线程同时操作 List 或 Dictionary 会不会有问题?你是怎么避免的?
- 客户端收到消息后为什么放到队列而不是直接处理?
- 如果现在要支持 100 人同屏,你的协议可以怎么优化?
- 服务端收到位置同步消息后,是怎么决定广播给谁的?
- 如果玩家断网,服务端多久会发现?心跳超时时间设置为多少合理?
这些问题都不需要背标准答案,只要你在开发过程中真正理解了每一步,就能自然回答。如果面试官问到你没做过的优化,可以大方承认并说出自己的思考方向,这比编造经验更受认可。
11. 后续扩展方向与实用建议
第一优先级是补上断线重连。现在的实现中,服务端会在 30 秒心跳超时后断开客户端。你可以设计一个掉线保护机制:客户端检测到连接断开后自动重连,服务端在短时间内允许玩家回到原房间,而不是直接解散房间。
第二优先级是消息压缩与批量同步。目前位置同步是一个包发一次,带宽浪费比较高。你可以把多个玩家的位置打包成一个 BatchSync 消息,减少包头数量和网络往返。
第三优先级是在同一个项目中对比帧同步。如果你是投客户端岗位,能主动写出“UDP 帧同步版本的故事原型”会更有加分,但那属于进阶操作,不要在基础版还没跑通时急着做。
对 27 届求职的同学来说,建议按这个节奏推进:
- 花两天把服务端监听、客户端连接、心跳、位置同步跑通。
- 花一天补上房间系统和一个采集玩法点。
- 花半天整理代码结构和面试问题清单。
- 在面试前自己录一段双人联机演示视频。
这个项目不要追求大而全,核心是把网络层链路讲透。能回答清楚 TCP 为什么适合这个场景、粘包拆包怎么处理、异步回调怎么保证线程安全,面试官对这个项目的评价就已经超过大多数候选人了。把最小闭环做完、跑熟,再根据自己投递方向做针对性扩展,才是这类求职项目最正确的打开方式。