简介:这份资源是基于ET4.0框架开发的斗地主Demo,面向希望快速上手ET游戏服务器框架的初中级开发者。包内共10487个文件,以cs脚本、meta资源、bin与dll(编译产物)、png(界面素材)、xml(配置与文档)等类型为主,整体约81.99MB;压缩包包含Server服务器端代码、Unity客户端工程、Proto协议定义、使用说明及构建目录,便于对照学习分布式部署、网络通信、热更新与Unity客户端接入等关键环节。资源目前已有1133人学习浏览。通过学习该Demo,可理解ET框架的Actor模型与事件驱动机制,掌握服务器与客户端的代码组织方式,熟悉协议定义及热更新流程,适合作为从理论转向实际游戏开发的重要练习项目。
1. 基于ET框架的斗地主Demo:一次把服务端架构和联机流程讲透的实战
想做斗地主联机Demo,选ET框架的理由很直接:它把客户端和服务器用同一套C#语言、同一套ECS架构组织起来,写服务端逻辑和写客户端逻辑用的是同一批思维模型,开发效率比传统TCP+回调的写法高出一截。这个Demo不是做个单机发牌动画,而是要把匹配、建房、发牌、出牌、结算这条完整链路跑通,让两台设备能真实对战。适合的人群是:已经跑通过Unity基础教程、想进入联机游戏开发但又不满足于只写个“收发消息”的例子、打算用ET框架做正式项目的团队或个人。这篇文章从框架核心概念讲起,落地到具体的代码和命令,最后把那些文档里不会写的坑都整理出来。
2. ET框架的底层逻辑:为什么斗地主这种玩法天然适合ECS加Actor模型
2.1 ECS架构里的“地主”“农民”“牌桌”分别是谁
ET框架的核心是Entity-Component-System。要理解这个,你先忘掉Unity里GameObject挂MonoBehaviour的习惯。在ET里,一个Entity就是一个有Id的对象,Component是挂在Entity上的数据片段,System是处理这些数据的逻辑。斗地主里:牌桌是一个Entity,玩家是一个Entity,每张牌也可以是一个Entity——但实际项目中牌不会建Entity,那样太浪费,牌用Component或普通数据结构去存更合理。
写ET代码时,你的习惯要改成“先想清楚谁是什么,再想谁挂什么数据”。比如Player Entity挂一个HandCardsComponent,字段是List ,存牌的编号;再挂一个PlayerStateComponent,字段是枚举,记录当前是叫地主、出牌还是旁观。逻辑则放在System里,比如出牌校验这样的纯逻辑方法,可以做成一个静态方法,也可以挂到某个System上。ET的框架代码把这些组织得很规整,新手最怕的就是拿到项目不知道从哪下手,我的建议是:先读Entity、Component这两个基类,再读一个完整的消息处理流程,比如登录流程,把“Entity发消息→服务器收到→处理→回消息→客户端收到→处理”这条链路读明白,再去动业务代码。
有一件事必须提醒你:ET框架的文档版本迭代很快,网上很多教程基于ET5.0或更早版本,而当前主流的ET6.0以上版本目录结构大变。你搜到的很多“ET框架详解”里的代码路径已经对不上了。我在下文写到的路径以ET6.x为参照,如果你用的是其他版本,路径需自行调整,这一点后面“避坑章节”会再展开。
2.2 Actor模型如何承载“房间”这种天然隔离的玩法
斗地主的一个显著特点是:每一局游戏是一个独立的房间,房间内的玩家只关心自己房间内的牌局状态,房间与房间之间不需要频繁通信。这正好是Actor模型的用武之地。ET框架中,每个Actor有一个唯一的ActorId,消息发到ActorId上,Actor内部串行处理。你在服务器上开一个房间,这个房间就是一个Actor;玩家加入房间,其实是把自己的消息绑定到房间这个Actor上。出牌、叫地主、抢地主,这些消息都发给房间Actor,房间Actor按状态机推进牌局。
这样设计的好处是:你不需要自己去写加锁逻辑。多个玩家同时操作,例如三个农民同时点击出牌,服务器收到消息后是串行处理的,不会出现两个玩家同时改变了手牌数据导致内存冲突。这一点看着简单,实际写起来很值钱——传统多线程网络模型里,处理房间内共享数据要小心翼翼的加锁,出牌、加倍、托管这些操作同时触发时很容易出并发bug。Actor模型把并发复杂度降到零。
在实际开发中,房间Actor内部通常还挂着一些组件。比如TimerComponent用来处理出牌超时,如果玩家20秒不操作,自动出最小的牌;再比如GameStateComponent记录当前是叫地主阶段还是出牌阶段。ET的Actor模型天然支持这种组合,你把需要的Component往房间Entity上一挂,逻辑就齐了。
2.3 事件系统和双端共用代码怎么影响斗地主的逻辑组织
ET框架的事件系统是一个发布订阅模型。客户端和服务器都可以发事件、监听事件。斗地主里的典型场景:客户端收到服务器广播的“有玩家出了牌”消息,在UI层弹一个出牌动画。动画播完,需要通知逻辑层“动画结束,可以更新手牌区域了”。这类时序问题用事件系统处理很舒服。
事件系统落实到代码里,形如:某个类继承EventHandler,监听一个自定义事件;在另一个地方调用EventSystem.Instance.Send()。ET的发布订阅是同步的,也就是说,Send之后,监听者立刻执行。如果你需要异步等待,比如“等动画播完再执行下一步”,ET6提供了协程支持,用 async/await 配合 ETTask 可以实现。这里不是草率设计,而是你可以利用协程,在出牌动画的代码里直接写 await TimeComponent.WaitAsync(动画时长),再执行后续逻辑。这一下就避开了“回调地狱”。
双端共用代码是ET框架的另一大特点。你定义的消息结构体、常量的枚举、工具方法,放在一个共享程序集里,客户端和服务器都能引用。斗地主里的牌型判断——单张、对子、三带一、顺子、炸弹——这类纯逻辑函数,放在共享代码里,客户端做“预校验”提醒玩家“这手牌出不了”,服务器做“权威校验”防作弊,两端用同一套逻辑,不会出现“客户端觉得能出、服务器觉得不能出”的尴尬局面。
3. 用ET框架在本地把最小可玩链路跑通:从拉代码到首次登录
3.1 拉取源码与目录结构:先搞清楚你拿到手的是什么
ET框架本体在GitHub上有开源仓库,你需要通过Git把代码拉下来。常见做法是拉取发布分支或某个Release标签,而不是最新master——最新代码往往处于重构中,跟着跑容易中途撞上未知问题。以ET6.x为例,拉到本地后,你会看到几个关键目录:Unity(Unity工程)、Server(服务器工程)、Share(共享代码)。还有启动器相关的工程,用于启动服务器进程。
拉取命令大致如下:
git clone --recursive https://github.com/egametang/ET.git cd ET git checkout <某个Release标签> git submodule update --init --recursive参数说明:--recursive 是为了拉取子模块,ET框架依赖了一些子模块代码,不拉子模块会导致编译缺文件;checkout到某个Release标签,是因为ET一直在重构,不同版本之间目录结构差别很大,网上很多教程的代码路径是基于特定版本写的,你锁定版本后对照教程才不会踩“文件找不到”的坑。
3.2 用命令行编译并启动服务器进程
在Windows下,进入Server目录,找到对应的sln解决方案文件,用Visual Studio打开。首次编译会还原NuGet包,耗时比较长,需要耐心等。编译产物会在Server/Bin目录下生成,里面是一批dll和exe。启动服务器不需要手动一个个点击exe,ET框架在开发模式下通常是先启动一个名为“启动器”的控制台进程,它会按配置拉起多个子进程,比如负责登录的进程、负责房间逻辑的进程。
常见做法是在Visual Studio里把启动器项目设为启动项目,直接F5运行。如果你更习惯命令行方式,也可以这样做:
cd Server/Bin dotnet App.dll --AppType=Launcher --AppId=1参数说明:--AppType=Launcher 表示以启动器身份运行,它会读取配置并拉起其他子进程;--AppId=1 是当前进程的编号,每个进程都要有一个唯一AppId,用于进程间通信和消息路由。启动成功后,控制台会打印各子进程的启动日志,看到类似“Start Init OK”这样的输出,基本可以确认服务器层起来了。
3.3 在Unity端连接服务器:改一个地址和端口
打开Unity工程后,先确认ET框架要求的最低Unity版本。ET6.x通常需要2020.3或更高版本,用太老的Unity版本会直接编译报错。Unity工程里有一个初始化场景,场景里有一个全局的Game对象,上面挂着模式选择组件,你可以把当前模式设为开发模式(Development),并在配置里填上服务器IP和端口。
这个步骤最简单,也最容易翻车。很多人卡在“连不上服务器”,排查下来是填了localhost,但手机或另一台电脑访问时并没有把IP改成局域网地址。正确做法是:同一局域网下,把IP填成运行服务器那台机器的局域网IP,比如192.168.1.100,而不是127.0.0.1。如果你在云服务器上跑,填公网IP;还要确认防火墙和云安全组放行了对应端口,ET默认的端口从10000往后比较多,具体看配置文件。
3.4 跑通第一对消息:从登录按钮到服务器回复
现在你有了一个能跑的服务器和一个能连上服务器的Unity客户端。下一步就是把登录消息跑通。ET框架的消息定义在共享代码中,用Proto格式定义。登录相关的消息大致是这样的:
message C2R_Login // 客户端发给服务器,请求登录 { string Account = 1; string Password = 2; } message R2C_Login // 服务器返回登录结果 { int32 Error = 1; string Message = 2; string Token = 3; }字段说明:这里的数字1、2、3是字段编号,用于Protobuf序列化时识别字段,不能重复,修改字段名不影响协议兼容性,但修改字段编号会破坏兼容性。Error为0表示成功,非0表示失败,具体错误码表需要约定好。
Unity端点击登录按钮后的代码逻辑大致如下,这是一个简化版的调用方式:
// 发送登录请求的Demo代码(客户端侧) async ETTask OnLoginBtnClick() { // 构建请求消息 C2R_Login request = new C2R_Login(); request.Account = accountInput.text; request.Password = passwordInput.text; // 通过Session发送,并等待响应 R2C_Login response = (R2C_Login)await session.Call(request); if (response.Error != 0) { Log.Error($"登录失败: {response.Message}"); return; } // 保存Token,后续进入游戏流程会用到 PlayerPrefs.SetString("token", response.Token); // 登录成功,切换到游戏场景 await SceneManager.LoadSceneAsync("GameMain"); }代码说明:session.Call是一个请求响应模式的方法,底层会发送消息并挂起协程等待响应,直到服务器返回才继续往下执行。这种写法比注册回调的方式直观很多。这里使用的session是当前客户端到服务器的一条逻辑连接。调用前要确保session已经完成连接步骤,否则会抛出异常。
这套最小链路跑通后,你就有了一个“客户端发消息→服务器处理→客户端收到响应”的闭环,接下来做斗地主玩法,都是在这个闭环之上扩展。每次加新功能前,先确认这个闭环还在,开发效率会稳得多。
4. 斗地主核心玩法落地:房间、发牌、出牌与结算的代码实现
4.1 房间管理与匹配逻辑:三个玩家凑一桌的过程是怎么写的
斗地主的第一个核心是“凑齐三个人”。你可以做最简单的匹配逻辑:玩家点击“开始匹配”后,服务器把他加入一个匹配队列;当队列里人数达到3人时,创建一个房间Actor,把三个玩家加入房间。这里用ET框架的Actor模型,实现起来很干净。
服务器端的伪代码如下:
// 当匹配队列人数达到3人,创建房间并分配玩家 public static async ETTask<Room> CreateRoomForThreePlayers(List<Player> players) { Room room = EntityFactory.CreateWithId<Room>(Game.Scene, IdGenerater.GenerateId()); room.AddComponent<DeckComponent>(); // 牌堆 room.AddComponent<GameStateComponent>(); // 游戏状态:叫地主->出牌->结算 room.AddComponent<TimerComponent>(); // 出牌计时 for (int i = 0; i < players.Count; i++) { // 将玩家加入房间,分配座位号0、1、2 await room.AddPlayer(players[i], i); } // 通知所有玩家,房间已创建 M2C_RoomCreated roomCreatedMsg = new M2C_RoomCreated(); roomCreatedMsg.RoomId = room.Id; room.Broadcast(roomCreatedMsg); return room; }代码说明:EntityFactory.CreateWithId创建了一个新的房间实体,AddComponent挂上所需要的组件,让牌堆、游戏状态、计时逻辑各司其职。把创建房间和加入玩家分开,是为了复用——比如以后做“好友开房”,可以直接创建房间再邀请好友加入,不必走匹配队列。
这里的AddPlayer内部还需要给每个玩家生成一个座位号。座位号在斗地主玩法里很重要,因为叫地主是轮流按座位号来的,出牌顺序也是按座位号循环。实现时用一个简单的取模算法即可:当前出牌玩家座位号 = (上个出牌玩家座位号 + 1) % 3。
4.2 发牌逻辑:洗牌、发牌与手牌的序列化传输
斗地主的牌是一副54张的扑克牌,用0到53编号,其中52、53是大小王。洗牌用Fisher-Yates算法,这是概率均匀且实现简单的经典做法。发牌时,按座位号轮流各发17张,留3张底牌。底牌在叫地主结束后归地主所有。
洗牌和发牌的代码可以放在共享代码中,因为客户端做“预展示”时可能也需要知道某副牌长什么样。代码如下:
// 洗牌并返回一副乱序的牌组 public static List<int> ShuffleCards() { List<int> cards = new List<int>(); for (int i = 0; i < 54; i++) { cards.Add(i); } // Fisher-Yates洗牌 Random random = new Random(); for (int i = cards.Count - 1; i > 0; i--) { int j = random.Next(i + 1); (cards[i], cards[j]) = (cards[j], cards[i]); } return cards; }代码说明:洗牌逻辑的位置放在共享代码里有一个直接好处:以后做“观战”或者“回放”功能,客户端可以本地重放某一局,不需要服务器把每一张牌的数据都传过来,只需传一个种子数,然后客户端用同样的洗牌算法生成同样的牌序。这比传整副牌省流量。
发牌后,服务器需要把“各玩家的手牌”和“底牌”广播给客户端。这里的消息字段设计要留意,不能只发“我的手牌”,还要发玩家的座位号,客户端才能在自己的UI上把三家玩家的手牌显示在对应的位置(自己的牌在下方,左右玩家只显示数量)。消息结构大致是每个玩家一个PlayerCards字段,内容为手牌数组。
一个容易忽视的细节:牌的数据用int列表传,序列化出来体积很小,但在客户端做UI绑定时,手牌通常需要排序,所以客户端拿到牌后要先排序再绑定到UI。这个排序逻辑也建议放在共享代码里,保证排序规则一致——比如从大到小排列,牌型分析时就不用关心顺序了。
4.3 出牌校验与出牌广播:服务器永远是正确的裁判
出牌阶段是状态机最复杂的部分。每个玩家出牌时,服务器要做两件事:第一,校验玩家出的牌是否符合牌型规则;第二,校验是否大过上家。这两步一个都不能少,少了就会有人作弊打出非法牌型。
牌型判断是斗地主最核心的纯逻辑。常见做法是写一个PokerHelper静态类,提供方法如bool CheckCardType(List<int> cards)和bool CompareCards(List<int> curCards, List<int> lastCards)。这里不展开所有牌型,只展示一个关键逻辑——炸弹的判断与比较,因为炸弹的处理特殊,既属于牌型校验,又影响倍率:
// 判断是否为炸弹(四张点数相同的牌) public static bool IsBomb(List<int> sortedCards) { if (sortedCards.Count != 4) { return false; } int firstValue = GetCardValue(sortedCards[0]); for (int i = 1; i < 4; i++) { if (GetCardValue(sortedCards[i]) != firstValue) { return false; } } return true; }代码说明:GetCardValue把牌的编号(0到53)映射为点数(3到2,再到大小王),这种映射关系需要在工具类里统一定义。炸弹的判断逻辑很简单,但在校验“是否能压过上家”时,炸弹是跨牌型参与比较的唯一特例:普通牌型只能压同牌型,而炸弹可以压任何牌型;两个炸弹相遇时再比较点数。这个特判逻辑千万别漏。
服务器校验通过后,将出牌消息广播给房间内所有玩家。这里建议广播一条统一的消息,包含出牌者座位号、出的牌列表、以及剩余手牌数量,这样客户端拿到消息后知道怎么更新界面。这里有一个设计决策需要提前做:要不要广播手牌的具体内容?我的建议是——公开自己的出牌即可,不要广播未出的手牌,防止玩家通过内存查看器作弊。其他玩家只需要看到“剩了5张”即可,之后“报单”提示也是基于剩余数量做的。
4.4 胜负结算:农民与地主各拿各的分,倍率怎么累加
斗地主结算的核心是积分计算。默认一局基础分是1分,叫地主加倍、抢地主加倍、炸弹加倍、春天加倍。这些倍率需要在游戏过程中不断累加,存于房间的GameStateComponent中。
结算逻辑伪代码如下:
// 结算:isLandlordWin表示地主是否赢了 public static SettlementInfo CalculateSettlement(GameStateComponent state, bool isLandlordWin) { int baseScore = state.BaseScore; // 基础分,比如1 int multiplier = state.Multiplier; // 当前累计倍数 int landlordScore = 0; int farmerScore = 0; if (isLandlordWin) { landlordScore = baseScore * multiplier * 2; // 地主赢两家 farmerScore = -baseScore * multiplier; } else { landlordScore = -baseScore * multiplier * 2; farmerScore = baseScore * multiplier; } SettlementInfo info = new SettlementInfo(); info.LandlordScore = landlordScore; info.FarmerScores = farmerScore; // 两家农民各得该分 return info; }参数说明:state.BaseScore是底分,通常为1,也可以做成一局内的变量,由房间创建者配置;state.Multiplier在每次叫地主、抢地主、炸弹打出时累加,这个累加时机需要注意,必须在动作生效时立刻更新,不要在结算时才统一算,否则容易漏算。结算后通知所有玩家的消息里,要带每个玩家的最终得分和当前的倍率列表,客户端做结算界面的展示。
5. 避坑与排查:ET框架做斗地主时最常遇到的5个坑
5.1 实体ID对不上,消息发到了不存在的玩家身上
现象:服务器统计日志时发现,某些“出牌”消息被路由到了已退出的玩家,导致房间内其他玩家卡在等待状态。
原因:ET框架的实体有一个InstanceId,一个实体销毁后Id可能被复用,或者消息里存的还是旧的实体ID。常见于玩家断线重连后,旧的实体被销毁、新的实体被创建,但房间内广播列表没有更新。
解决:确保在玩家加入房间和退出房间时,都更新房间内的玩家实体引用列表。断线重连逻辑要单独做:重连时通过Token找到玩家的新实体,再把它重新挂到原来的房间下。我的习惯是:所有消息处理前先校验实体是否存在,不存在则直接丢弃并打印告警日志。
5.2 手牌集合用List存储,移除多张牌时索引越界
现象:玩家一次性出了多张牌,比如顺子或者飞机,服务器移除手牌时抛异常,把整个出牌流程打断。
原因:用for循环遍历List并从List中Remove元素,删除前面的元素后,后续元素的索引改变了,导致越界。
解决:不要边遍历边删除。推荐做法是先把要出的牌收集到一个HashSet中,然后一次性重建剩余手牌列表。代码如下:
// 从手牌中移除指定的牌 public static List<int> RemoveCards(List<int> handCards, List<int> cardsToRemove) { HashSet<int> removeSet = new HashSet<int>(cardsToRemove); List<int> result = new List<int>(handCards.Count); for (int i = 0; i < handCards.Count; i++) { if (!removeSet.Contains(handCards[i])) { result.Add(handCards[i]); } } return result; }代码说明:这里为了避免重复牌的问题(手牌里有两张5,你只出了一张5),实际项目中不能用HashSet直接去重,而是要把每张牌当作“一个可区分个体”,用“牌编号+手牌下标”或记录每张牌唯一标识的方式处理。更简单可靠的做法是遍历原手牌,逐张标记被移除的牌,而不是用值去重。这一点需要特别小心,否则会出现“两张同点数只移除了一张”的隐患。
5.3 出牌轮转逻辑写错,导致玩家乱序出牌
现象:某个玩家出牌后,下一个轮到的玩家不对,有时候跳过一个玩家,有时候又回到自己。
原因:轮转逻辑用了固定顺序,而斗地主里存在“农民一伙、地主单独”的特殊轮转规则:当某个玩家出牌后,其他两家如果都不要,则这个玩家重新出牌。没有保存“当前最大牌是谁出的”这个状态。
解决:在GameStateComponent里增加LastPlaySeat和LastPlayCards字段,每次出牌时更新这两个字段。当两个玩家连续选择“不出”后,轮转重置为LastPlaySeat出牌。
5.4 客户端动画节奏没处理,导致UI状态和服务器状态不一致
现象:收到服务器广播的出牌消息后,客户端立刻更新手牌UI,但出牌动画还没播完,玩家已经点了下一手牌,导致UI显示和最终逻辑结果对不上。
原因:客户端逻辑直接依赖服务器消息驱动UI,没有做一个“动画期间禁用操作”的缓冲。
解决:在客户端加一个简单的状态锁。收到出牌广播后,先播动画,动画播完前禁止玩家点击任何操作按钮;动画播完后,解锁并刷新UI。实现可以用ET的协程工具,出牌动画的代码前面已经提到过,await一个动画时长即可。
5.5 压测时内存只升不降,服务器运行几小时就卡死
现象:多开客户端跑了一晚上,服务器进程占用的内存在持续增加,最后操作卡顿。
原因:ET框架的日志系统在开发模式会打印全量日志,而日志写到内存中未及时释放;另外,有些事件订阅后没有反订阅(Unsubscribe),导致已经销毁的实体还在被事件系统引用,内存无法回收。
解决:开发模式下临时把日志级别调高,比如只输出Error以上日志;同时在实体销毁的Dispose方法中,把该实体订阅过的所有事件都取消订阅。这两种做法能显著降低内存泄漏风险。正式压测前,先用小规模客户端跑半小时,观察内存曲线是否平稳,是判断泄漏的快速方法。
6. 进阶验证与优化:从Demo走向可长期维护的小项目
Demo跑通后,还有几步能拉开差距。第一个是压测:至少开10个客户端进程,同时匹配、同时出牌,观察服务器的CPU、内存和消息延迟。ET框架自带一些性能统计工具,你可以把每轮出牌的服务器处理耗时打印出来。正常情况下,一次出牌消息从收到到广播给三个客户端,耗时应该低于10毫秒;如果高于50毫秒,说明逻辑里有阻塞或过重的同步操作,优先排查日志打印和数据库操作。
第二个是日志的规范化。开发调试时,把关键业务日志打全;但发布后,只保留登录、创建房间、结算这类关键操作日志,出牌日志可以降级为Debug级别,否则磁盘撑不住。ET框架的日志系统支持按级别过滤,这个配置花十分钟就能学会,性价比很高。
第三个是断线重连。Demo阶段可以不处理断线,但要做到可演示的完整程度,至少要保证:玩家在出牌过程中断线,服务器等待10秒后自动代打(自动出最小的牌),并且玩家重连后能回到原房间继续游戏。这个功能一开始就考虑,能为你后续做正式项目省下大量重构时间。代打逻辑代码量不大,但对框架理解的提升很有效。
第四个是客户端表现层的优化。比如手牌拖动、出牌飞行动画、提示“要不起”的按钮置灰,这些做得细不细,直接决定别人看了Demo之后觉得“这是个玩具”还是“这已经像产品了”。我见过不止一个团队,服务端逻辑完善,但Demo的UI停留在“按钮+文字”,展示时观感差很多。斗地主的交互表现在手牌排序动画和出牌飞行动画上,你花一周时间打磨这两个细节,比加十个功能都有效。
做这个Demo,我的经验是把“验证框架和学习成本”当作第一目标,不要一味堆功能。我早期做某个联机Demo时,花了太多时间在复杂的匹配算法上,结果基础消息链路反而不稳,展示时频频出状况。后来收敛目标,先把三人在同一房间里完整打一局跑通,再逐步加匹配、加托管、加重连,路反而走得更顺。
最后分享一个我的个人习惯:每次写完一关键节点,比如发牌、出牌校验、结算,我都会用两个客户端实测三个玩家的完整对局,并在本子上记录每步操作后的界面状态。很多“玄学”问题,最后查出来都是服务器广播了消息但客户端刷新逻辑漏写了。养成把消息流和UI状态对应起来的调试习惯,你后面做任何联机功能都会顺很多。希望帮到你。
本文还有配套的精品资源,点击获取