简介面向Unity开发者的可联机餐厅经营游戏完整源码使用C#编写覆盖服务端、客户端与共享工程三个项目解决局域网/互联网联机中数据同步、房间内状态转发等核心问题。资源共913个文件以C#脚本、dll库、fbx模型、prefab预制体、asset资源、png贴图、mp3音频等类型为主压缩包约215MB整体文件类型配套齐全便于直接打开工程或对照学习。这份源码采用TCP/IP进行客户端与服务端通讯MySQL负责游戏数据存储服务端作为中介接收并广播房间内客户端状态从而同步所有玩家数据。框架上以静态类作为公用变量与方法的中介单例模式降低代码耦合、结构简洁适合作为中大型Unity联机项目的架构参考。已有645人学习下载适合具有C#与Unity基础、希望进阶网络游戏开发的读者。1. 联机餐厅游戏难点不在餐厅而在网络同步拿起一份“C#基于Unity的餐厅经营游戏源码”如果只打开Unity工程你会发现自己根本“联”不起来。餐厅里顾客点单、厨师做菜、服务员上菜这些逻辑都在客户端但联机需要多个客户端看到同一个餐厅状态。这个资源给出的方案很典型用一个独立的服务端做数据中转和数据库读写客户端只负责表现和输入中间用TCP/IP串起来。它适合两类人一是想做联机Demo交课程设计的在校生二是刚接触Unity网络开发、想看看服务端与客户端如何协作的一线程序员。下面从架构拆解开始逐步把每个环节的代码和参数讲透。2. 三工程架构服务端、客户端、共享代码的划分与单例桥接2.1 为什么要拆三个工程很多Unity开发者习惯把一切逻辑都塞进场景里的MonoBehaviour挂脚本需求一变就乱套。这份源码把项目拆成服务端、客户端、共享工程三个独立部分核心原因在于职责隔离。服务端是无头程序不关心盘子长什么样只维护数据库里的数据、接收客户端的操作请求、并把结果转发给同一房间的其他客户端。客户端是Unity工程管渲染、动画、UI和玩家输入。共享工程被两边同时引用放的是双方都需要的消息类型和公共工具方法。拆开之后最直接的好处是你可以单独启动服务端不用打开Unity就能调试数据库读写。只要共享工程里的消息协议不变服务端可以换语言重写客户端也可以换渲染引擎耦合度被压到最低。对于多人协作来说这也意味着负责服务端的人不需要完整跑Unity资源节省了版本冲突和编译时间。2.2 共享工程把“公共方法”放进静态类源码框架里提到的“单例模式”实际用的是静态类而不是经典的Instance属性单例。静态类在C#里不能实例化所有成员都是静态的调用方式直接。在三工程结构下共享工程里最常见的是定义命令字和全局配置namespace Shared { public static class NetCommands { public const int EnterRoom 1001; public const int PlayerReady 1002; public const int OrderFood 2001; public const int ServeFood 2002; public const int UserList 3001; public const int RoomState 3002; } public static class GameConfig { public const int MaxPlayersPerRoom 4; public static float CookingTime { get; set; } 3f; } }这段代码定义了网络命令字和全局配置。静态类的价值在于任何场景下都能直接通过NetCommands.OrderFood访问常量不需要new对象也不需要考虑生命周期。当你要让“某个方法在客户端和服务端都能用”时放进静态类是成本最低的选择。比如判断玩家是否已点单的方法两端调用同一个实现就不会出现服务端认为“未点单”而客户端显示“已点单”的错位。需要警惕的是静态类不等于全局变量滥用。座位状态、订单列表这些可变数据不应该全塞进静态类否则多房间并发时会互相污染。正确做法是静态类只放“房子结构”不放“房客数据”。2.3 服务端TCP监听、MySQL读写与请求处理2.3.1 线程池与客户端连接管理服务端使用TCP/IP监听客户端连接。主流做法是开一个后台线程跑TcpListener.AcceptTcpClient()每个连接再交给线程池处理。关键点在于不要在每个请求到来时重建连接而是用TcpClient维持长连接因为餐厅经营游戏需要实时同步状态短连接的握手开销会明显拉高延迟。TcpListener listener new TcpListener(IPAddress.Any, 7777); listener.Start(); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ Task.Run(() HandleClient(client)); }代码中IPAddress.Any表示监听本机所有网卡地址端口7777可按防火墙规则修改。AcceptTcpClientAsync是异步版本避免阻塞主线程。后面的Task.Run把每个客户端连接调度到线程池这样即使某个客户端乱发数据导致异常也不会拖垮其他连接的接收循环。2.3.2 数据查询与回写的SQL参数化服务端收到客户端操作请求后需要读写MySQL。这份源码没有用ORM框架而是直接用MySql.Data驱动。写SQL时务必使用参数化查询不要拼接字符串否则玩家名里带单引号时轻则查询报错重则被SQL注入。using (var conn new MySqlConnection(server127.0.0.1;databaserestaurant;userroot;password******)) { conn.Open(); string sql UPDATE t_player SET gold gold delta WHERE player_id pid; using (var cmd new MySqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(delta, earnGold); cmd.Parameters.AddWithValue(pid, playerId); cmd.ExecuteNonQuery(); } }这个片段里AddWithValue把参数delta和pid与C#变量绑定避免SQL注入。MySqlConnection和MySqlCommand都用using包裹让连接及时归还连接池。生产环境下连接串里的明文密码应通过环境变量或配置文件注入不要写死在代码里。2.4 客户端从网络消息到UI更新Unity客户端这边网络层一般挂在一个空物体上用NetworkClient组件负责建立连接和接收流。收到数据后不能直接改场景对象的属性因为TCP流会粘包你收到的可能是一半消息或两条消息叠在一起。源码在共享工程定义了一个消息头前四个字节记录消息体长度后续是JSON数据。客户端解析时先读长度头再按长度截取JSON反序列化成业务对象最后通过事件或委托通知UI刷新。UI刷新时要特别注意Unity的线程限制网络线程不能直接调用场景对象必须把回调抛到主线程。常见做法是使用一个ConcurrentQueueAction在Update()里逐帧消费。这套机制会在下一章详细展开。3. 联机同步协议从“发什么”到“怎么发”3.1 消息格式长度头加JSON体上一章提到了长度头加JSON的消息封装这一节给出具体定义。餐厅经营里一个典型消息可能是“玩家下了一单”public class OrderMessage { public int cmd; // 命令字对应NetCommands.OrderFood public int roomId; public int playerId; public int tableId; public int menuId; public int count; }序列化时先把OrderMessage序列化成JSON字符串再计算它的UTF-8字节长度写入一个字节数组的前四个字节随后拼接消息体。接收端先读取四个字节得到长度再读相应字节的JSON。之所以用长度头是因为TCP是流协议没有消息边界加长度头比用分隔符更稳妥。下面的表是这份源码里常见的命令字分配cmd含义流向1001进入房间客户端→服务端1002玩家准备客户端→服务端→广播2001点单客户端→服务端→广播2002上菜客户端→服务端→广播3001用户列表服务端→客户端3002房间状态服务端→客户端命令字的分配原则是1000段用于房间管理2000段用于游戏行为3000段用于状态同步。这样在调试时看到日志里的数字就能判断消息类别。3.2 服务端的房间广播逻辑服务端接收到订单后除了写数据库还需要把订单转发给房间里其他客户端。这里的核心是服务端维护一个Dictionaryint, ListTcpClientkey是房间号value是房间内所有连接的列表。转发时遍历列表但不转发给发起者本人。源码中服务端会先把消息经过计算和验证再统一广播而不是收到什么就转什么——比如扣金币前先查玩家余额余额不足就直接返回错误码不触发广播。void Broadcast(int roomId, int exceptClientId, byte[] data) { if (!_rooms.TryGetValue(roomId, out var clients)) return; foreach (var c in clients) { if (c.ClientId exceptClientId) continue; c.Stream.Write(data, 0, data.Length); } }这段代码的逻辑是找出房间后逐客户端检查连接ID跳过发起者。data是已经带上长度头的完整报文。参数exceptClientId用于避免给“自己”回显同样的订单状态减少UI重复刷新。实际项目中Stream.Write可能会半包更严谨的做法是维护一个发送队列写完后确认发送完毕。3.3 客户端的状态机响应客户端不能收到什么消息都立刻改状态。餐厅经营有明确的状态流转空闲、已点单、上菜、结算。我把客户端逻辑设计成一个简单状态机每个cmd对应一个状态迁移函数。比如收到OrderFood广播后客户端把对应桌子的状态从“空闲”改为“已点单”同时播放点单动画。这里有一个容易被忽视的点JSON里的playerId可能是别人你需要先判断它不是自己才走别人的动画如果是自己则只更新UI按钮状态避免重复响应。void HandleOrder(OrderMessage msg) { if (msg.playerId localPlayerId) { ui.OrderButton.interactable false; return; } tables[msg.tableId].SetStatus(TableStatus.Ordered); }在这个方法中localPlayerId在进入房间时由服务端下发的UserList消息初始化。OrderButton.interactable false表示自己的按钮置灰防止重复下单。给别人的桌位播动画时建议把tableId作为索引不要遍历所有桌子找匹配否则桌子多时会有性能浪费。3.4 避免UI卡顿异步回调与主线程调度很多人搜“c# 循环数据采集和ui刷新卡顿”在Unity里本质是跨线程访问UI。网络收包线程收到状态数据后如果直接调用Text.text 或者Slider.value Unity会报“get_text can only be called from main thread”。正确姿势是同步到主线程再执行UI更新。private readonly ConcurrentQueueAction _mainThreadQueue new(); void Update() { while (_mainThreadQueue.TryDequeue(out var action)) action?.Invoke(); } public void EnqueueUiAction(Action action) { _mainThreadQueue.Enqueue(action); }把上面的EnqueueUiAction放进网络接收线程所有需要改UI的逻辑都通过它抛到主线程。ConcurrentQueue保证多线程入队安全Update()每帧取出并执行。特别注意主线程队列里的动作必须是轻量级的不能做数据库查询动作积压过多会导致掉帧。一般建议合并同一帧内对同一UI控件的多次修改例如顾客满意度滑块一帧内只保留最后更新的值即可。4. 把源码跑起来环境配置、建表与联调排错4.1 Unity与Blender资源导入拿到源码后先分清三个工程。服务端和共享工程是独立的C#控制台/类库项目客户端是Unity工程。打开客户端的Assets目录会看到Blender导出的模型文件.fbx或.blend和动画片段比如Cut.anim、NoCut.anim。资源导入时注意Unity版本兼容老版本的.anim文件在2020以上版本可能提示“AnimationClip internal data mismatch”重新导入或重烘焙动画即可。如果你没有装Blender不需要重新建模资源里已经导出了FBX文件。但如果你要改人物动作保留.blend源文件会更好在Blender里修改后重新导出坐标轴选择-Z朝前与Unity的左手坐标系一致。导出材质时贴图路径要用相对路径否则换一台机器打开会变成粉红色。4.2 MySQL建表与初始化数据数据库这个环节是很多初学者卡住的地方。源码里应该有.sql文件如果没找到就按服务端代码里出现的字段反向建表。典型结构如下CREATE TABLE t_player ( player_id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(32) NOT NULL, gold INT DEFAULT 100, level INT DEFAULT 1, last_login DATETIME ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_room ( room_id INT PRIMARY KEY, status TINYINT DEFAULT 0, create_time DATETIME ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表语句里player_id是自增主键gold给初始值方便测试。room_id一般由客户端请求开房后由服务端分配。注意字符集用utf8mb4否则存入玩家名里的表情符号会报错。执行完SQL后用Navicat或mysql命令行手动插入一条测试账号INSERT INTO t_player (user_name, gold) VALUES (tester, 500);插入这条数据后客户端可以用tester账号登录服务端连接串里的databaserestaurant要和建表所用库名一致。4.3 本机联调一个服务端加两个客户端联调第一步启动服务端。确认MySQL已开启且连接串里的127.0.0.1能连通。第二步打开Unity客户端在NetworkClient组件上填服务端IP和端口。如果想和身边朋友联机IP要填局域网地址不能填127.0.0.1。第三步复制一份Unity客户端出来改一下玩家名两个客户端同时连接同一个服务端才能在房间列表看到彼此。这里最容易出现的问题是“连不上”。先检查服务端窗口有没有打印连接日志再看Windows防火墙是否放行了7777端口。命令行里用netstat -an | findstr 7777确认端口处于LISTENING状态。如果两个客户端在同一台机器不要把第二个客户端改成另一个端口客户端角色是对称的都连服务端而不是互联。提示如果你的Unity客户端和服务端都在同一台机器请优先用127.0.0.1测试若换了局域网IP连不上八成是防火墙问题而不是代码问题。4.4 高频报错排查WebGL写入失败、阴影异常、滑动条响应很多人喜欢把Unity客户端发布成WebGL版用浏览器玩此时会遇到“unity 发布 webgl 使用 idbfs 写入失败”的报错。这个报错源于浏览器不允许网页默认持久化数据Unity的Application.persistentDataPath在WebGL下会变成IDBFS虚拟文件系统。解决办法是在服务端开启MySQL存档不要依赖本地存档如果非要本地缓存则需要在index.html里提前注册IDBFS的同步文件系统并在页面加载后调用FS.mount。源码里如果客户端有自动存档功能发布WebGL前一定要把这个分支去掉否则启动就会写入失败。另一个坑是光照阴影问题。场景里的Boxophobic.SkyboxCubemapExtended是天空盒材质改成实时光源后如果角色投射阴影出现黑块优先检查模型是否有重叠的法线或者把阴影类型改成“Soft Shadows”并在Player Settings里勾选“Auto Graphics API”避免OpenGL和D3D阴影算法不一致。还有一个常见需求是“用Unity做一个滑动条”在经营游戏里用于音量设置或满意度指示。如果你直接拖Slider到场景数值没反应看看Slider.onValueChanged是否绑定到方法Unity事件不绑定是不会执行任何代码的。而且网络游戏里滑动条本地拖动后要发送cmd给服务端其他客户端才能看到该玩家调整的数值不要只在本地写PlayerPrefs。5. 架构复用把“餐厅”换成“棋牌”或“竞速”5.1 通用房间管理器的抽象与验证这套三工程联机架构不只服务餐厅经营。把餐厅里的点单、上菜、结账抽象成不同的cmd换一套游戏规则就是一个新的联机游戏。关键点在于业务逻辑只放在服务端客户端只做表现。比如棋牌游戏发牌、出牌、胜负判定都在服务端客户端收到“出牌”广播后播放出牌动画并更新手牌UI。竞速游戏则反过来客户端负责物理模拟服务端定期接收玩家坐标快照并广播给他人。建议做一个通用房间管理器服务端把当前房间所有可用的cmd和对应参数列表做成配置表客户端动态加载。餐厅需要“菜单”“桌子状态”棋牌需要“牌堆”“轮次”。配置表存MySQL或JSON都行服务端启动时加载客户端连接后下载。public class RoomController { private readonly IRoomLogic _logic; public void SetLogic(IRoomLogic logic) { _logic logic; } public void Dispatch(int cmd, string payload, int fromClientId) { _logic.Handle(cmd, payload, fromClientId); } }上述代码里IRoomLogic是所有游戏逻辑的接口。餐厅工程实现RestaurantLogic棋牌工程实现CardLogic。Dispatch方法根据cmd调用对应业务。注意复用服务端时建议把数据库连接、客户端连接管理部分保留只替换逻辑实现。最后提供一个验证迁移成功的小技巧写一个压力测试脚本用TcpClient模拟20个客户端同时发随机操作观察服务端多久能广播回来。如果延迟稳定在100ms以内说明架构没有明显瓶颈。这时再把自己新游戏的消息结构套进共享工程你就能在一天内拥有一个可以联机的Demo。本文还有配套的精品资源点击获取
