ET框架斗地主Demo实战:棋牌服务端核心机制与踩坑全解

发布时间:2026/10/6 3:48:37
ET框架斗地主Demo实战:棋牌服务端核心机制与踩坑全解 简介这是一份基于ET框架4.0版本开发的斗地主Demo工程面向希望入门ET游戏服务器框架的开发者尤其适合具备一定C#和Unity基础、想了解分布式游戏架构与服务器客户端联调的初学者。包体为7z压缩格式共10487个文件大小约81.99MB包含C#源码、Unity场景与预制体、协议定义文件、DLL编译产物及配置文档等其中Server目录提供服务器逻辑Unity目录包含客户端工程Proto定义网络通信协议使用说明.txt提供运行指引。已有1132人学习。通过对照该Demo可以直观理解ET框架的分布式部署、Actor事件驱动、热更新等核心机制学习如何组织服务器与客户端代码、实现网络通信与协议解析并借助完整的斗地主玩法串联起从联机匹配到出牌结算的完整逻辑是理论结合实践的良好入门素材。1. 基于ET框架的斗地主Demo为什么说它是棋牌服务端入门的完整闭环ET框架在C#游戏后端圈子里属于那种“看文档觉得懂了打开解决方案就懵”的框架。基于ET框架的斗地主Demo正好是打破这个僵局的切入点它不只是一堆示例代码而是一局完整棋牌游戏的闭环——登录、匹配、发牌、叫地主、出牌、结算、断线重连全链路都在这个demo程序里跑得通。想做棋牌类项目的从业者或者刚接触分布式游戏服务器的新手都能靠它把实体组件系统、Actor消息、热更新这些ET核心概念真正落到代码层。下文我会带你把服务端牌局逻辑和客户端接入链路拆开看顺带讲清楚那些最容易让你翻车的细节。2. ET框架的核心设计实体树、组件与Actor消息怎么撑起斗地主房间我接触ET的第一天就被它的实体树结构绕晕了。后来带着“我要用ET写一局斗地主”这个具体目标回头看反而很快理顺了。ET的本质是围绕实体Entity、组件Component、系统System三个概念组织的服务端框架再叠加一套Actor消息系统支撑分布式节点间的通信。斗地主房间这种强状态、强顺序的业务恰好是这套设计的理想试验田。2.1 实体树与组件一局斗地主被拆成了哪些实体在ET里几乎一切业务对象都是实体实体还可以挂在其他实体下面形成实体树。斗地主Demo的房间内常见的实体树长这样Game/Scene游戏场景根实体Room房间实体Desk牌桌实体DeckQueue牌堆实体ThrowArea出牌区实体Player玩家实体HandCards手牌组件SeatInfo座次组件实体树的价值在于生命周期管理房间被销毁它下面的牌桌、牌堆、玩家手牌全部跟着释放不用担心内存泄漏。这一点在棋牌服务端特别重要——每天开几千局只要有一局的状态没清干净内存就会缓慢爬升最后翻车在半夜三点。组件则是挂在实体上的数据和行为单元。ET的约定是“组件只做自己那一份事”比如座次管理、牌型比较、结算分别拆成独立组件。示例代码如下// 房间实体承载一局斗地主的核心状态 public class Room : Entity { public int RoomId { get; set; } public int Round { get; set; } // 当前局数 public int CurrentSeat { get; set; } // 当前出牌座位号 public int PassCount { get; set; } // 连续过牌计数达到2次则出牌权跳转 } // 房间工厂创建房间并挂载需要的组件 public static class RoomEntityFactory { public static Room Create(Scene scene, int roomId) { Room room scene.AddChildRoom(); room.RoomId roomId; room.AddComponentSeatComponent(); // 座次与发牌顺序 room.AddComponentCardRuleComponent(); // 牌型识别与比较 room.AddComponentRoundSettleComponent(); // 每局结算 return room; } }逻辑说明AddChildT把Room实体挂到Scene下这一步决定了房间的生命周期由Scene统一管理。AddComponent则是组合式的功能装配跟ECS的语义一致。参数说明RoomId是房间的唯一标识一般由匹配服务分配CurrentSeat用0、1、2表示三个玩家的座次出牌顺序绕圈递增即可。新手在这里最容易犯的错是“把逻辑全塞进Room实体类里”看起来方便但后面热更新、多人协作开发时会互相踩脚。我一般会坚持组件拆分宁可多写几个文件也不要在Room里堆上千行方法。这个习惯在Demo阶段体现不出来等项目上量就值钱了。2.2 Actor消息牌桌通信为什么必须排队而不是直连RPC斗地主里最怕什么怕的是玩家A还没出完牌玩家B的“出牌”消息就到了牌局当场错乱。传统RPC是请求-响应模型天然没有顺序保证而ET的Actor模型给每个实体挂一个Mailbox所有投递过来的消息先进队列再由实体逐条处理。这就相当于给牌桌通信上了一道串行锁省去了自己写锁的麻烦。Actor模型还有一个硬性好处消息处理天然支持跨进程迁移。玩家从Gate服登录但房间可能开在别的进程上发送Actor消息时只需要拿对端实体的地址不需要关心它在哪台机器。斗地主Demo虽然规模小但这个设计让你将来扩展分布式部署时不动业务代码。// 投递给Room实体的Actor消息 public class Actor_PlayerPlayCard : EntityActorMessage { public long PlayerId { get; set; } public Listbyte Cards { get; set; } // 出牌列表 } // 在房间内部处理该消息 public class RoomPlayCardHandler : EntityActorHandlerRoom, Actor_PlayerPlayCard { protected override async ETTask Handle(Room room, Actor_PlayerPlayCard message) { // 校验当前是否轮到这个玩家 if (room.CurrentSeat ! room.GetComponentSeatComponent().GetSeat(message.PlayerId)) { Log.Warning($玩家{message.PlayerId}不是当前出牌人); return; } // 校验牌型并落桌 bool ok room.GetComponentCardRuleComponent().TryPlay(room, message.Cards); if (ok) { room.CurrentSeat (room.CurrentSeat 1) % 3; room.PassCount 0; } await ETTask.CompletedTask; } }逻辑说明自定义消息继承EntityActorMessage处理类继承EntityActorHandlerTEntity, TMessageET框架会自动把消息路由到对应Handler。参数说明PlayerId是玩家在全局的唯一IDCards是要打出的牌集合GetSeat负责从玩家ID反查座位避免客户端伪造座次。这里没有加锁因为Actor消息会按顺序进入Room的Mailbox同一时刻只有一个Handler在处理Room的业务。踩坑提示千万别在Handler里直接调用await阻塞耗时操作比如数据库IO。棋牌房间的Actor消息处理是串行的一个消息卡住整桌人的出牌全部卡死。正确的做法是先把业务状态改完再发异步消息去做落库。2.3 Demo的启动链路与服务配置一处错误就进不来房间ET的典型启动链路是登录服校验账号 - Gate服建立客户端连接 - 匹配服撮合三路人马 - 创建房间并拉人进入。斗地主Demo通常默认只有一台服务器所有进程合在一起跑但目录结构仍然保留多进程拆分。提前看懂启动顺序后面排查“连通了却没反应”类问题会快很多。启动顺序一般是这样启动Scene根节点驱动接入服、网关服、匹配服注册客户端连接Gate端口发送账号登录请求登录成功后客户端请求进入匹配队列匹配满三人后匹配服调用RoomEntityFactory.Create创建房间三个客户端收到“进入房间”消息牌局开始这里的核心配置在ServerConfig.json里接线错了会导致客户端能连上网关但匹配后收不到房间消息{ IP: 127.0.0.1, Port: 10001, GatePort: 10002, MatchPort: 10003, InnerPort: 10004, OuterIP: 127.0.0.1 }参数说明Port是客户端连接Gate的对外端口InnerPort是服务端内部进程间通信端口OuterIP在联机调试时改成局域网IP不然手机端连不上。MatchPort是匹配服监听端口。我见过最典型的翻车现场程序里写死了127.0.0.1服务端跑在云服务器上客户端跑在本地匹配消息永远发不出去日志里全是超时。还有一点要提醒ET框架的不同版本对配置项命名有出入有的版本把GatePort并进了Port字段有的版本引入了单独的RouterPort。拿到一份Demo先别急着改业务代码把启动流程跑通再用二分法定位问题这是玩ET的基本功。3. 服务端牌局逻辑把发牌、叫地主、出牌、结算写成能跑的代码这一章是Demo的重头戏。棋牌服务端和Web后端的最大区别在于核心逻辑不是CURD而是一套带状态流转的规则引擎。斗地主的规则在纸面上谁都能说两句但落到代码里牌型判定、顺序流转、特殊牌型王炸、癞子的边界才是真正区分Demo和产品的分水岭。3.1 手牌的数据表示与牌型判定从54张牌到一串byte我见过有人用字符串“3-4-5-6-7”表示一手牌调试直观但做牌型比较时解析太痛苦。斗地主Demo里最主流的做法是直接用byte表示牌0-12表示方块A到K13-25表示梅花A到K26-38表示红桃A到K39-51表示黑桃A到K52给小王53给大王。这样处理的好处是牌型比较只需要算“点数”不需要管花色。点数可以这样映射牌值除以13得到点数0表示A1表示23表示3……12表示K但斗地主里2是最大的单牌所以需要一张映射表。写成代码更清楚public enum CardType { None, Single, Pair, Triple, TripleWithSingle, TripleWithPair, Straight, StraightPair, Bomb, FourWithTwo, Rocket } public static CardType CheckType(Listbyte cards) { if (cards.Count 0) return CardType.None; // 王炸两张牌且包含大小王 if (cards.Count 2 cards[0] 52 cards[1] 53) return CardType.Rocket; // 按点数分组获取点数和数量 var groups cards .GroupBy(GetPoint) .OrderBy(g g.Key) .Select(g new { Point g.Key, Count g.Count() }) .ToList(); // 炸弹四张相同点数 if (groups.Count 1 groups[0].Count 4) return CardType.Bomb; switch (groups.Count) { case 1: return groups[0].Count switch { 1 CardType.Single, 2 CardType.Pair, 3 CardType.Triple, _ CardType.None }; case 2: // 三带一 / 三带二 / 四带二 break; case 3: // 三带一 单牌、三带二 对子 break; } // 顺子、连对等由Count和各组Count判断 return CardType.None; } private static int GetPoint(byte card) { if (card 52) return 16; // 小王 if (card 53) return 17; // 大王 int p card / 13; // 斗地主中2最大A其次3最小 return p 1 ? 15 : (p 0 ? 14 : p - 2); }逻辑说明GetPoint把牌值转成斗地主比较用的点数2点是15A是143是2。CheckType先处理王炸和炸弹再按分组数量和每组张数判断牌型。参数说明这里的分组逻辑注意OrderBy(g g.Key)是必须的顺子要求点数连续排序后方便判断。示例里顺子和连对的完整分支我做了省略落地时补上连续判断即可。这段代码的坑在GetPoint的映射。斗地主的点数顺序是3 4 … K A 2 小王 大王直接拿card / 13做比较A会被当成最小2会被当成第二小牌局必炸。我因为这个映射表翻过车输出单牌时明明打出了2系统却提示“压不过对面的3”。3.2 叫地主流程用一个状态机撑起整局节奏牌局不是一出牌就到底的它必须严格按阶段推进发牌结束进入叫地主叫地主结束才进入出牌阶段。用状态机管理阶段比用一堆bool标记位可靠得多因为bool多了之后你根本不知道哪些组合是合法的、哪些组合永远不该出现。public enum GameStage { Dealing, Bidding, Doubling, Playing, Settling } public class RoomStateComponent : Component { public GameStage Stage { get; private set; } public void ChangeStage(GameStage newStage) { // 阶段流转校验不允许从Dealing直接跳到Playing bool valid (int)newStage (int)Stage 1; if (!valid) { Log.Error($非法的阶段流转: {Stage} - {newStage}); return; } Stage newStage; Log.Info($房间阶段切换到: {newStage}); } }逻辑说明ChangeStage强制走完每一个阶段任何跳阶段的操作都会被日志拦下来。这样调试的时候打开日志就能看到牌局走到哪一步了。参数说明Stage使用private set外部只能通过ChangeStage切换这是状态机最基本的原则——不要让阶段值被随意改动。叫地主的环节通常还会带上叫分和抢地主比如三家轮流喊1分、2分、3分或“不叫”。我一般会把叫分逻辑单独抽一个BidComponent里面维护当前最高叫分、最高叫分人、轮询指针。Demo阶段可以简化成随机地主或者固定座位但保留状态机的骨架后面接真规则就是填空。3.3 出牌校验与回合流转压牌、过牌、轮转出牌阶段是所有棋牌Demo里逻辑密度最高的地方。一次出牌至少要校验三件事轮到这个玩家没有、牌型合法不合法、压没压过上家。三者缺一不可。public static bool CanBeat(Listbyte current, Listbyte last) { if (last null || last.Count 0) return true; // 首家出牌随意 CardType currentType CheckType(current); CardType lastType CheckType(last); if (currentType CardType.None) return false; if (currentType CardType.Rocket) return true; // 王炸压一切 if (lastType CardType.Rocket) return false; // 王炸不能被压 if (currentType CardType.Bomb lastType ! CardType.Bomb) return true; // 炸弹压非炸弹 // 同牌型比较张数必须一致然后比点数 if (currentType ! lastType || current.Count ! last.Count) return false; return GetMaxPoint(current) GetMaxPoint(last); } private static int GetMaxPoint(Listbyte cards) { return cards.Select(GetPoint).Max(); }逻辑说明CanBeat处理了四种关键分支——王炸、炸弹、同牌型比较、普通压牌。参数说明last为空表示这轮没人出牌当前玩家重新开一轮。注意Bomb在比较点数时斗地主规则里炸弹也不看点数只看谁先出所以示例返回true表示可以直接炸。回合流转的逻辑在各家框架里大同小异记录上家玩家ID、保存上家出的牌、把CurrentSeat移动到下一个有效座位。这里有个容易忽略的细节如果下家已经被淘汰Demo通常不淘汰但真实棋牌有“春天”需要跳过无效座位继续找下一个。public void NextValidSeat(Room room) { SeatComponent seats room.GetComponentSeatComponent(); do { room.CurrentSeat (room.CurrentSeat 1) % 3; } while (!seats.IsValidSeat(room.CurrentSeat)); }逻辑说明IsValidSeat检查该位置是否有正常在线的玩家、是否已经离开。Demo阶段三个人常驻这层判断显得多余但实际项目中玩家断线后座位还在但状态置为“离开”出牌必须跳过它不然整局卡死。这个函数虽小却是处理断线重连的基石。4. 客户端接入Unity里从登录到出牌动画的完整链路斗地主Demo的客户端通常用Unity开发。服务端逻辑再完善客户端接不进来Demo就只是半成品。这一章讲透客户端接入链路的三个关键环节连接与登录、手牌同步、出牌反馈。整个过程走通了你才算真正把ET跑起来了。4.1 连接与登录客户端怎么和服务端建立会话ET客户端不是直接在代码里new一个Socket然后收发字节流而是通过ET的Session组件建立抽象连接。客户端启动时要先加载配置文件把Gate地址和端口读进来再发起连接。// Unity脚本里初始化客户端并连接Gate public class GameBootstrap : MonoBehaviour { private async void Start() { // 从配置表读取Gate地址端口默认10002 string gateAddress GlobalConfig.Instance.GateAddress; int gatePort GlobalConfig.Instance.GatePort; // 创建客户端Session并连接 Session session Game.Scene.GetComponentNetClientComponent() .Create(gateAddress, gatePort); // 发送登录消息账号密码仅为Demo演示 var loginMsg new C2G_Login { Account test_user, Password 123456 }; G2C_Login response (G2C_Login)await session.Call(loginMsg); if (response.Error 0) { Debug.Log(登录成功玩家ID: response.PlayerId); } } }逻辑说明Create方法建立底层连接Call方法以请求-响应方式发送登录消息并等待服务端确认。参数说明这里的C2G_Login和G2C_Login是拼好的协议类字段用MongoDB.Bson序列化ET框架会自动处理编解码。Error字段是ET的通用返回码0表示成功。新手最容易在这段出问题连接地址写成了localhost但Unity编辑器跑在Windows上服务端跑在Windows的另一个进程里localhost没问题如果服务端跑在虚拟机上就必须填虚拟机IP填localhost就会“连接被拒绝”。日志里能看到的是SocketException: Connection refused但你不看配置根本意识不到是地址写错。4.2 同步手牌服务端发牌后客户端怎么渲染登录进房间后服务端会广播一局开始并把手牌发给每个玩家。客户端监听消息更新UI列表。这里要注意的坑是ET的消息回调在异步线程或者网络线程不能直接在回调里操作Unity的GameObject。// 监听服务端发来的手牌消息 public class HandCardComponent : Entity { private readonly Listbyte _handCards new Listbyte(); public void OnDealCard(Actor_DealCard message) { _handCards.Clear(); _handCards.AddRange(message.Cards); // 切割场景把数据更新切到Unity主线程执行 Game.Scene.GetComponentCoroutineLockComponent().Wait(0).Coroutine(); Game.EventSystem.Publish(new Event_HandCardChanged { Cards _handCards }); } }逻辑说明OnDealCard先存数据再通过事件系统把UI刷新切回Unity主线程。参数说明Actor_DealCard里的Cards是服务端分配好的一组byte客户端拿到后按GetPoint映射成点数再对应到图片资源。Event_HandCardChanged是客户端自定义的事件参数UI脚本订阅这个事件刷新牌面。这里的跨线程问题是Unity开发的老生常谈。很多人第一次跑通Demo后发现手牌有时候能显示有时候是空的多半是消息在非主线程直接改了UI列表然后在主线程渲染时读到不一致的数据。ET自带的EventSystem已经做了分发封装请务必走它不要在自己写的Handler里直接调用Text.text xxx。4.3 Demo程序的调试姿势日志、断点与Unity编辑器斗地主的逻辑链条长我调试时最常用的既不是Debug.Log也不是断点而是一份“状态快照”。每收到一条Actor消息就把它打出来同时在房间状态切换的地方打一条带GameStage的日志。这样一旦牌局走偏回看日志就能定位是哪条消息在捣乱。另一个实用技巧为了验证判牌逻辑我会在Unity里加一个“模拟出牌”的面板直接输入一组牌值例如[3,4,5,6,7]后台调用CanBeat看返回结果。这个动作能让牌型判定的问题在编辑器里暴露大半不用等联调时再和同事互相甩锅。客户端日志建议输出到文件而不是只打在Unity控制台。控制台在手机端跑的时候是不可见的FileLog能把日志直接写到本地手机连上USB调试也能拉取。用这个方式排查真机上的“连不上服务器”“点按钮无响应”问题效率高得多。5. 斗地主Demo踩坑记录从连不上到不同步的五个现场这里整理的内容全部来自我自己带Demo时真实遇到过的故障现场。每个问题都按“现象 → 原因 → 解决”记录你可以直接在项目里对号入座。5.1 现象客户端连接超时登录一直转圈原因最常见的有三种一是服务端没启动二是端口配置不一致三是防火墙没放行。ET框架启动时如果端口被占用会直接抛SocketException但客户端不会收到任何报错只会一直卡在等待状态。解决先用命令行netstat -ano | grep 10002确认服务端端口是否在监听再用telnet 127.0.0.1 10002测试连通性。如果你用的是云服务器记得在安全组里放行对应端口这一点跟本地调试没有任何关系但确实是最容易忽略的。5.2 现象叫地主之后三人全部卡死日志停在“等待叫地主响应”原因这是Actor消息没有发到Room的Mailbox。常见场景是服务端通过普通静态方法直接调了Room的逻辑而不是走ActorMessageDispatcher分发或者消息投递时用了实体ID而没注册Actor。ET里Actor消息默认只处理注册了ActorLocation的实体Room实体如果没登记消息会静默丢弃。解决在创建Room后调用Game.Scene.GetComponentActorLocationComponent().Add(room.Id, room)完成注册。同时检查消息类是否继承了正确的基类以及Handler是否被ET的扫描器识别到。扫描器不认识时日志会有警告不细看很容易漏掉。5.3 现象出牌校验提示“打出的牌不合法”但手里的牌明明存在原因牌值映射表写错了。例如GetPoint(53)返回了16而王炸判定写的是cards[0] 52 cards[1] 53两者数据本身没问题但如果你在比较点数时用Math.Min算牌面小王会被当成最小的牌而牌型校验时又把它当最大自相矛盾。解决写一组针对牌型判定函数的单元测试把54张牌逐一打印出来人工核对每个牌值的点数和牌型识别结果。这个动作花不了十分钟但能避免你在联调阶段被队友反复追问“你代码有问题吧”。5.4 现象热更新后逻辑没变改的代码完全没生效原因ET框架的代码分热更程序集和非热更程序集。如果你把牌型判定的代码放进了非热更的Model程序集发布时它被打进了固定DLL而ET热更逻辑在客户端启动时会重新加载两者版本不一致你改的代码自然不生效。解决检查你改的文件所属的程序集。ET的目录约定是Model放实体类和数据处理Hotfix放逻辑和Handler如果逻辑文件放在了Model把它挪到Hotfix下重新编译即可。如果Demo用的框架版本已经做了全量热更那就直接检查客户端是否加载了新DLL这一步只要有一步错代码版本都对不上。5.5 现象客户端显示的牌局状态和服务端对不上对家明明没出牌客户端却显示“过了”原因这是典型的客户端预测与服务端权威不一致。Demo里常见做法是客户端收到出牌消息后立刻刷新UI但没等服务端确认成功就执行。如果服务端因为校验失败拒绝了这张牌客户端已经显示“出掉了”而服务端认为你还没出两边就错位了。解决把客户端的出牌操作改成“先发Actor消息等服务端回复成功再刷新UI”。Demo阶段不用做预表现纯等回包足够流畅。预表现是产品阶段为了手感加的东西Demo阶段别给自己找麻烦。6. 进阶从Demo到上线前需要补的四块短板Demo跑通只是开始真要拿它做产品至少还有四件事要补。第一房间复用与回收。现在的Demo是匹配完成就创建房间、结算完就销毁但上线后玩家可能连续玩几十局创建销毁的房间对象会造成GC压力。我一般会做一个RoomPool把空房间缓存起来下局直接复用复用前要清理玩家手牌和阶段状态。第二数据持久化。Demo通常不落库但正式环境必须有战绩、金币流水、玩家信息的三张表结算时在Actor消息处理完后再异步写入。第三断线重连的协议。Demo里断线就全剧终产品阶段玩家掉线要能在一个时间内重进房间恢复牌局这需要把房间状态做成可快照的。第四压测。至少要做并发几百局的牌局压力测试重点观察Actor Mailbox是否有堆积以及Gate层连接数是否膨胀。我现在每接手一个棋牌项目都会先要求团队把上面的牌型判定函数写满单元测试再把启动流程写进README。这两个习惯是从这个斗地主Demo的血泪经验里学来的。框架跑通是第一步跑稳才是基本功。如果你正在用ET做棋牌方向我建议你也照着这个顺序把Demo吃透等你把房间状态机的每一跳都能讲出为什么再谈上线就从容多了。希望帮到你。本文还有配套的精品资源点击获取