27 届求职做 Unity 多人联机项目,最容易踩的两个坑:一个是只做了单机 Demo,简历上写“多人”实际拿不出手;另一个是直接拖 Mirror / Photon 插件,面试官一问 TCP 原理就答不上来。
这次我们来看一个适合写进简历的完整项目:基于Unity + C# Socket 服务端的TCP 多人联机合作生存游戏。客户端不用现成网络框架,服务端自己用TcpListener写,从 socket 监听、消息编码、分包粘包处理到房间管理全链路打通。作为 27 届求职实习作品,它的优势是技术栈够底层、能讲清楚协议设计、还能现场 demo 多人联机效果。
本文将按“架构设计 → 服务端实现 → 客户端网络层 → 玩法同步 → 联调测试 → 简历包装”的顺序展开,代码可以直接复制改造成自己的项目。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | Unity 客户端 + C# Socket 服务端的联机合作生存游戏 |
| 客户端技术栈 | Unity 2021 LTS 及以上,C#,UGUI,System.Net.Sockets |
| 服务端技术栈 | .NET 6 / .NET Framework 4.7.2,TcpListener,Async/Await |
| 网络协议 | TCP,自研长度前缀消息协议 |
| 核心玩法 | 多玩家同房间合作采集、击杀怪物、生存计分 |
| 联机规模 | 支持 4~8 人同房间,服务端可承载多房间 |
| 启动方式 | 服务端控制台启动,Unity 客户端编辑器/打包后启动 |
| 是否支持 API | 服务端预留消息接口,可对接日志、机器人压测脚本 |
| 是否支持批量任务 | 支持批量创建房间、批量启动机器人做压力测试 |
| 简历价值 | 体现 Socket 编程、协议设计、多线程、Unity 生命周期与网络层分离能力 |
实际显存、内存占用取决于场景规模和同时在线人数,本地联调用默认参数即可跑起来,后续可按需压测。
2. 项目架构与技术选型
2.1 为什么不用 Mirror / Photon
27 届求职实习,技术面试官最关注的不是你会不会拖插件,而是你对网络同步的理解是否到位。
Mirror 这类高层框架帮你封装了 Spawn、RPC、SyncVar,看起来开发快,但面试一问“TCP 粘包怎么处理”“服务端如何广播消息”“玩家掉线怎么判断”,还是得回到 socket 层。
自研服务端的好处是:
- 面试能说清楚消息从客户端到服务端再广播回去的完整链路;
- 可以控制协议格式,按自己需求设计心跳、房间号、玩家 ID;
- 服务端是独立 C# 控制台程序,不依赖 Unity 运行环境,压力测试更直观;
- 展示代码组织能力和排错能力,这些正是实习岗位看重的基本功。
2.2 整体架构
Unity Client A ─┐ Unity Client B ─┼── TCP ──> C# Socket Server Unity Client C ─┘ ├─ ConnectionManager(连接管理) ├─ PacketParser(粘包/分包处理) ├─ RoomManager(房间管理) └─ GameLogic(游戏规则)服务端进程与 Unity 客户端进程分开,客户端只负责渲染和输入,服务端负责状态计算与广播。这种结构在后续扩展同步方案时更灵活。
2.3 目录结构规划
建议直接把项目拆成两个目录:
SurvivalGame/ ├─ Server/ // C# 控制台服务端 │ ├─ Program.cs │ ├─ ConnectionManager.cs │ ├─ PacketParser.cs │ ├─ RoomManager.cs │ └─ Messages.cs └─ Client/ // Unity 项目 ├─ Assets/ │ ├─ Scripts/ │ │ ├─ Network/ │ │ ├─ Game/ │ │ └─ UI/ │ └─ Scenes/服务端和客户端分离,后续发版、压测、维护都不混在一起。
3. 环境准备与前置条件
3.1 服务端环境
- Windows / Linux / macOS 均可,推荐 .NET 6 或更高版本控制台应用;
- 也可以直接用 Unity 安装目录自带的 Mono 环境,但独立 .NET 工程更好调试;
- 安装 .NET SDK 后,执行命令创建服务端项目:
dotnet new console -n SurvivalServer cd SurvivalServer3.2 客户端环境
- Unity Hub 安装Unity 2021 LTS 或 2022 LTS,个人开发用 Personal 授权即可;
- 联网模块直接用 .NET 的
System.Net.Sockets,不需要安装 Unity 包; - UI 采用 UGUI,无需额外资源;
- 开发期建议准备两个窗口运行客户端:一个在编辑器中运行,一个打包成 Windows 可执行文件后运行,模拟多端联机。
3.3 网络环境与端口
服务端默认监听端口建议用8888。如果端口被占用,启动时会报这类错误:
error: listen tcp 127.0.0.1:8888: bind: only one usage of each socket address出现该信息,说明端口被其他进程占用,换端口或结束占用进程即可。
4. TCP 基础回顾:三次握手与四次挥手
既然项目是 TCP 联机,面试一定会问基础,这里先梳理一次。
TCP 面向连接,传输前需要建立连接。
三次握手(建立连接)
- 客户端发送 SYN,请求连接;
- 服务端收到后回复 SYN + ACK,表示确认;
- 客户端再回复 ACK,连接建立。
对应到代码层面,就是TcpClient.Connect/TcpListener.AcceptTcpClientAsync的过程。
四次挥手(断开连接)
- 主动关闭方发送 FIN,表示数据发送完毕;
- 被动关闭方回复 ACK;
- 被动关闭方发送 FIN,表示也准备关闭;
- 主动关闭方回复 ACK,连接关闭。
实际开发中,断线不总是规范的挥手流程,比如客户端崩溃、切后台、网络切换,TCP 可能感知不到对端离线。所以业务层必须加心跳机制,在心跳超时后主动剔除死连接。
5. C# Socket 服务端实现
5.1 服务端主入口:监听与接受连接
先写一个最小可运行的Program.cs,支持监听端口和异步接受客户端连接。
using System; using System.Net; using System.Net.Sockets; using System.Threading.Tasks; class Program { private static TcpListener _listener; static async Task Main(string[] args) { int port = 8888; _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); Console.WriteLine($"Server started, listening on {port}"); while (true) { TcpClient client = await _listener.AcceptTcpClientAsync(); Console.WriteLine($"Client connected: {client.Client.RemoteEndPoint}"); // 每个客户端连接进入独立会话处理 _ = HandleClientAsync(client); } } static async Task HandleClientAsync(TcpClient client) { // 交给 ConnectionManager 处理收发消息 await Task.CompletedTask; } }这里用AcceptTcpClientAsync实现非阻塞接收新连接。后面的_ = HandleClientAsync(client)是 fire-and-forget 模式,适合把每个连接放入独立异步会话。
5.2 连接管理与客户端对象
服务端需要维护每个客户端的连接状态,建议设计一个ClientSession类:
public class ClientSession { public string SessionId { get; set; } public TcpClient TcpClient { get; set; } public NetworkStream Stream => TcpClient.GetStream(); public string PlayerName { get; set; } public string RoomId { get; set; } public DateTime LastActiveTime { get; set; } }会话字典用ConcurrentDictionary或加锁的Dictionary保存:
private static ConcurrentDictionary<string, ClientSession> _sessions = new();避免多个线程同时修改字典造成冲突。
5.3 消息协议与分包处理
TCP 是流协议,没有消息边界。多个消息可能粘在一起(粘包),一个消息也可能拆成多次发送(半包)。最简单的协议格式是:
[消息体长度 int32][消息体字节流]发送端先写4字节长度,再写消息体;接收端统一按“读够4字节长度 → 读够长度内容”的逻辑循环解析。
public static class PacketParser { public static byte[] Encode(string json) { byte[] body = System.Text.Encoding.UTF8.GetBytes(json); byte[] header = BitConverter.GetBytes(body.Length); byte[] packet = new byte[header.Length + body.Length]; Buffer.BlockCopy(header, 0, packet, 0, header.Length); Buffer.BlockCopy(body, 0, packet, header.Length, body.Length); return packet; } }接收端使用缓冲流处理:
public static List<string> Decode(byte[] buffer, int received, ref byte[] cache) { List<string> messages = new(); // 合并缓冲与本次收到的数据 byte[] merged = new byte[cache.Length + received]; Buffer.BlockCopy(cache, 0, merged, 0, cache.Length); Buffer.BlockCopy(buffer, 0, merged, cache.Length, received); cache = merged; int offset = 0; while (cache.Length - offset >= 4) { int bodyLength = BitConverter.ToInt32(cache, offset); if (cache.Length - offset - 4 < bodyLength) break; // 数据不完整,等待下一次接收 string json = System.Text.Encoding.UTF8.GetString(cache, offset + 4, bodyLength); messages.Add(json); offset += 4 + bodyLength; } // 剩余未处理数据保留到缓存 if (offset > 0) { cache = cache.Skip(offset).ToArray(); } return messages; }这是自研 Socket 服务端必须解决的第一个核心问题,面试时能画出示意图比背概念更有说服力。
5.4 消息结构:约定 JSON 格式
消息体统一使用 JSON,便于扩展和调试。定义消息类型:
public class NetMessage { public string type { get; set; } public string data { get; set; } }type表示消息类型,比如"joinRoom"、"playerMove"、"heartbeat";data是具体业务数据的 JSON 字符串。客户端和服务端共用这套结构,保持字段命名一致。
6. 服务端核心业务逻辑
6.1 心跳机制与超时剔除
客户端定时发送心跳,服务端更新LastActiveTime;后台线程定期检查超时连接,超过 10 秒没心跳则断开。
async Task CheckTimeoutAsync() { while (true) { await Task.Delay(TimeSpan.FromSeconds(5)); DateTime now = DateTime.UtcNow; foreach (var pair in _sessions) { if ((now - pair.Value.LastActiveTime).TotalSeconds > 15) { Console.WriteLine($"Session timeout: {pair.Key}"); CloseSession(pair.Key); } } } }6.2 房间管理
合作生存游戏需要房间概念:玩家创建房间、加入房间、同步房间内成员位置和状态。房间数据结构:
public class GameRoom { public string RoomId { get; set; } public List<string> PlayerIds { get; set; } = new(); public int MaxPlayers { get; set; } = 4; }创建房间时生成唯一 RoomId,可以简单用 GUID 前缀:
string roomId = Guid.NewGuid().ToString("N").Substring(0, 6);房间满员后拒绝新玩家加入,并回复错误消息。
6.3 玩家进入与离开
玩家加入房间后,服务端向房间内其他玩家广播:
{ "type": "playerJoined", "data": "{\"playerName\":\"Alice\",\"playerId\":\"abc123\"}" }玩家离开或掉线时,广播playerLeft。这一步能保证所有客户端看到一致的房间成员列表。
6.4 位置与状态同步
合作生存游戏常见同步方案:客户端定时上报位置,服务端转发给同房间其他客户端。上报频率建议 10~15 次/秒,频率过高会增加带宽和 GC 压力。
if (msg.type == "playerMove") { // 转发给同房间其他人 BroadcastToRoom(session.RoomId, msg, excludeSessionId: session.SessionId); }广播方法遍历房间内所有会话并逐包发送。
7. Unity 客户端网络层实现
7.1 客户端连接服务端
Unity 端使用TcpClient,连接代码建议放在单独的NetworkManager类中:
using System; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; using UnityEngine; public class NetworkManager : MonoBehaviour { private TcpClient _client; private NetworkStream _stream; public string serverIP = "127.0.0.1"; public int serverPort = 8888; public async void ConnectToServer() { try { _client = new TcpClient(); await _client.ConnectAsync(serverIP, serverPort); _stream = _client.GetStream(); Debug.Log("Connected to server"); _ = ReceiveAsync(); } catch (Exception e) { Debug.LogError("Connect failed: " + e.Message); } } }强调一点:Unity 主线程不能阻塞,不要在Update里做同步读取,使用async/await或协程。
7.2 接收消息与主线程调度
NetworkStream的ReadAsync在底层线程池完成数据读取,拿到结果后需要切回 Unity 主线程才能操作 GameObject 和 UI。最简单的方案是用UnityMainThreadDispatcher或直接在异步方法结束后调用。
async Task ReceiveAsync() { byte[] buffer = new byte[4096]; byte[] cache = new byte[0]; try { while (_client.Connected) { int received = await _stream.ReadAsync(buffer, 0, buffer.Length); if (received == 0) { Debug.Log("Server closed connection"); break; } var messages = PacketParser.Decode(buffer, received, ref cache); foreach (var msg in messages) { // 切到主线程更新 UI UnityMainThreadDispatcher.Instance.Enqueue(() => { HandleMessage(msg); }); } } } catch (Exception e) { Debug.LogError("Receive error: " + e.Message); } }7.3 发送消息
发送操作相对简单,注意消息体不能跨线程发送的同时被其他线程修改:
public void Send(string type, string data) { if (_stream == null) return; NetMessage msg = new NetMessage { type = type, data = data }; string json = JsonUtility.ToJson(msg); byte[] packet = PacketParser.Encode(json); _stream.Write(packet, 0, packet.Length); }这里PacketParser需要在 Unity 工程中复制一份,保持客户端与服务端协议一致。
7.4 心跳发送
客户端启动后,定时发送心跳消息,防止服务端误判离线:
IEnumerator HeartbeatCoroutine() { var wait = new WaitForSeconds(5f); while (true) { Send("heartbeat", "{}"); yield return wait; } }服务端收到心跳后刷新LastActiveTime,双方时间窗口要对齐,避免频繁掉线。
7.5 断线重连与界面反馈
真实游戏中网络波动很正常,客户端要有断线提示和重连逻辑。收到连接关闭通知后,更新 UI 状态,并提供“重新连接”按钮。重连时先释放旧连接,再重新调用ConnectToServer()。
8. 合作生存玩法的同步设计
8.1 角色状态同步
每个玩家需要同步的核心状态包括:
- 玩家 ID 与名称;
- 位置(Vector3);
- 生命值(int);
- 状态(存活/倒下/复活中);
- 当前房间 ID。
这些数据放在服务端内存中,客户端每次上报位置时附带生命值等变化字段,服务端校验后广播。
8.2 资源采集与怪物击杀
为避免所有客户端各算各的造成不一致,规则统一放服务端:
- 玩家采集资源时发送
collectRequest; - 服务端检查资源是否存在、距离是否合法;
- 合法则扣除资源、更新玩家背包、广播
collectSuccess。
面试时可以重点讲这一步:为什么规则放服务端?答案是为了防止客户端作弊和状态不一致,这是多人游戏架构的基本共识。
8.3 合作复活机制
合作生存游戏的亮点玩法:玩家倒下后不会立刻退出,队友靠近并按住交互键可以复活。
实现上,倒下状态由服务端维护,客户端上报“请求复活”,服务端检查附近队友数量和交互时间后广播复活结果。这类玩法逻辑复杂度适中,很适合实习项目展示。
9. 本地联调与多开测试
9.1 启动服务端
在终端运行:
dotnet run --project SurvivalServer看到打印日志说明服务端已就绪:
Server started, listening on 88889.2 启动 Unity 客户端
方式一:Unity 编辑器中 Play 运行一个客户端;方式二:File -> Build Settings打包 Windows 平台,运行一个打包后的客户端。
两个客户端填入相同服务器 IP(本机填127.0.0.1)和端口8888,点“加入房间”,观察服务端日志是否有两条连接。
9.3 功能验证清单
| 测试项 | 操作 | 预期结果 |
|---|---|---|
| 连接 | 启动客户端点连接 | 服务端打印 Client connected |
| 创建房间 | 客户端创建房间 | 服务端生成 RoomId 并返回给客户端 |
| 加入房间 | 第二个客户端输入房间号加入 | 两个客户端都能看到对方加入提示 |
| 位置同步 | 控制角色移动 | 另一客户端看到角色移动,无明显延迟抖动 |
| 掉线处理 | 直接关闭一个客户端进程 | 服务端超时踢出会话,另一客户端收到 PlayerLeft |
| 端口冲突 | 手动修改端口为已占用端口启动服务 | 报 bind 错误,端口被占用 |
10. 服务端接口扩展与批量压测
10.1 服务端控制台命令
服务端可以增加控制台输入,方便测试:
static void ConfigureConsoleCommands() { while (true) { string line = Console.ReadLine(); if (line == "list") { Console.WriteLine($"Current sessions: {_sessions.Count}"); } else if (line == "rooms") { // 打印所有房间信息 } } }10.2 机器人压测
需要验证服务端承载能力时,可以写一个简单的机器人脚本模拟多个客户端连接:
for (int i = 0; i < 50; i++) { TcpClient client = new TcpClient(); await client.ConnectAsync("127.0.0.1", 8888); // 持续发送心跳和低频率位置消息 }压测时重点观察:服务端 CPU 使用率、内存增长、是否存在 GC 停顿、消息延迟是否增大。这部分数据写进简历,比单纯说“开发了多人联机游戏”更有说服力。
10.3 消息广播的压力优化
房间内玩家越多,广播量越大。优化思路:
- 多房间并行广播,至少一个房间一个循环;
- 避免在广播循环中执行耗时 IO;
- 同一玩家的位置更新可以合并后再转发;
- 如果后续要支持大规模人数,再考虑 Actor 模型或服务器分区。
11. 资源占用与性能观察
11.1 服务端性能观察
开发期最直观的方式是看任务管理器和服务端日志:
- 连接数:服务端打印会话数;
- 内存:观察
dotnet进程内存曲线是否持续增长; - CPU:多开 4 个客户端时 CPU 使用率情况。
如果内存持续增长,优先检查消息解析缓存是否有泄漏,尤其是cache数组是否在分包处理时不断扩容却不释放。
11.2 客户端性能观察
Unity 端使用 Profiler:
- 观察
NetworkManager.ReceiveAsync是否占用过多主线程时间; - 观察 JSON 解析是否造成 Update 卡顿;
- 观察渲染与 UI 更新是否在主线程频繁调用。
位置同步频率建议做成可配置参数,而不是写死。
public float syncInterval = 0.1f;把0.1f暴露在 Inspector 中,方便调试不同频率下的表现。
12. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 服务端启动报 bind 错误 | 端口被占 | 命令行输入netstat -ano | findstr 8888查看 PID | 结束占用进程,或修改监听端口 |
| 客户端连接超时 | 服务端未启动 / IP 错误 / 防火墙拦截 | 服务端打印日志;用ping测网络;本机测试先用 127.0.0.1 | 启动服务端;关闭防火墙或放行端口 |
| 收到消息乱码 | 编码不一致 / 协议长度不对 | 检查发送端与接收端PacketParser是否一致 | 统一使用 UTF-8,长度头用 Int32 |
| 客户端收不到广播 | 消息类型路由未处理 | 服务端打印广播日志 | 检查广播方法是否排除发送者或未带房间号 |
| 一段时间后自动掉线 | 心跳超时 | 查看服务端超时日志 | 检查客户端心跳协程是否被停掉,调整超时阈值 |
| 两个客户端位置抖动 | 同步频率过高或过低 / 未做插值 | 调整syncInterval;开启 Profiler | 降低上报频率,客户端做位置插值平滑 |
| 重启服务端后客户端恢复不了 | 未做重连逻辑 | 观察客户端日志中的异常 | 实现断线重连和状态重置 |
| Unity 编辑器里网络线程崩溃 | 子线程直接访问 GameObject | 看堆栈信息 | 用主线程调度器切线程后更新 UI |
网络热词里出现的error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket addre就是典型的端口占用问题,基本按上面第一行处理即可。
13. 这份求职作品的简历包装与面试讲解
13.1 项目描述模板
写在简历上的项目描述可以这样组织:
TCP 多人联机合作生存游戏(Unity + C# Socket 服务端) - 独立设计并实现基于 TCP 的多人联机架构,服务端为 C# 控制台程序,支持房间管理、玩家上下线与心跳检测 - 设计长度前缀消息协议,解决 TCP 粘包/半包问题,统一客户端与服务端消息格式 - 实现位置、生命值、资源采集与复活机制的服务端同步与广播,保证多客户端状态一致 - 开发服务器控制台命令与机器人压测脚本,验证 50 连接下的服务端稳定性 - 客户端使用 Unity 构建,网络层与游戏逻辑分离,支持断线重连和多端本地联调注意,简历不要写“我用了 Mirror”,而是把自研 Socket 和协议设计的细节写出来,因为这些都是面试官愿意深挖的点。
13.2 面试高频问题清单
| 面试问题 | 回答要点 |
|---|---|
| TCP 和 UDP 怎么选? | 需要可靠有序用 TCP,需要低延迟可容忍丢包用 UDP;本项目侧重可靠状态同步 |
| 粘包和半包怎么处理? | 长度前缀 + 缓冲区,解析时先读长度再读消息体 |
| 心跳机制为什么必要? | TCP 断线不总是能及时感知,应用层心跳用于超时剔除,释放服务端资源 |
| 服务端如何广播? | 维护会话集合和房间集合,遍历对应房间内会话逐个写包 |
| 客户端界面卡顿怎么办? | 网络收发异步化,消息解析和 UI 更新分批处理,接收 buffer 复用 |
| 为什么规则要放服务端? | 防止客户端作弊,避免玩家各自计算结果不一致,保证所有客户端世界观一致 |
| 状态同步频率如何设置? | 位置 10~15Hz 够用,高频操作(射击)可单独设计,避免无脑高频上报 |
| 一个房间最多支持多少人? | 由带宽、消息频率和广播 O(N) 决定,本项目设计为 4~8 人,压测后再调整 |
13.3 演示注意事项
面试时现场 Demo 要注意三点:
- 服务端与客户端都在本机,演示前确认端口没被占用;
- 准备两个窗口,一个编辑器一个打包程序,展示真实双端联机效果;
- 演示失败要有排查路径,比如用
netstat查端口、看服务端日志定位问题。
技术面试最加分的不是项目多炫,而是“出了问题你能快速定位原因并解决”。
14. 最佳实践与工程化建议
14.1 开发期建议
- 先写个最小的 TCP 回声服务验证通路,再逐步加入房间与玩法,避免一步到位出问题时无法定位;
- 服务端和客户端分开启动,日志分开看,加时间戳;
- 消息类型合并成常量或枚举,避免字符串写错导致路由失败;
- 每次修改协议后,客户端和服务端同步更新,最好维护一份协议版本号字段。
14.2 代码质量建议
NetworkStream的读写都异步化,避免阻塞主线程;- 缓冲区
byte[] buffer使用固定大小并复用,减少 GC; - 服务端定期扫描超时会话,而不是每收到心跳就开新线程;
- 所有跨线程操作 UI 的地方统一走主线程调度器,避免偶发崩溃。
14.3 游戏素材与合规提醒
合作生存游戏涉及角色、怪物、场景、音效等素材,如果使用网上下载的免费素材,注意确认作者授权范围,尤其是标注“仅限个人学习”的素材不要写进简历 Demo 对外展示。涉及玩家昵称、IP 等信息的收集,仅用于联机测试,不要在公开演示中泄露他人信息。
14.4 可扩展方向
- 加入 Kerberos 或 Token 登录鉴权;
- 将 JSON 协议替换为更紧凑的二进制协议;
- 将服务端数据持久化到 MySQL / SQLite,保存玩家战绩;
- 增加房间内聊天系统;
- 接入相关云服务器或部署到公网,做异地联机测试;
- 微调同步算法,加入移动预测和插值,提升体验。
15. 总结与下一步
这个项目最值得尝试的点,是打通“服务端 Socket 监听 → 消息协议编解码 → Unity 客户端网络收发 → 玩法状态同步”的完整链路。作为 27 届求职实习作品,它比直接套用现成框架更能展示底层网络编程能力。
建议拿到代码后最先验证三件事:
- 服务端能否正常启动并接受多个客户端连接;
- 客户端是否能在两个不同的 Unity 运行实例中看到彼此的位置同步;
- 杀掉一个客户端后,服务端能否在心跳超时后正确清理会话,另一客户端是否收到离开广播。
最容易踩的坑是端口冲突与粘包处理不完整。端口冲突看启动日志,粘包问题用固定长度前缀协议解决,先跑通这两个点,整个项目就顺了。
后续可以继续扩展的方向,一是把 JSON 协议替换为二进制协议,二是加入数据库存档,三是尝试把服务端部署到云主机做真机异地联机。每一步扩展都能成为简历上的新亮点。
如果这篇教程对你有帮助,建议收藏备用,做项目的时候对照测试。