☰
ET框架斗地主Demo实战:从环境搭建到前后端消息打通
2026/10/14 4:24:54 网站建设 项目流程

简介:基于ET开源游戏服务器框架4.0版实现的斗地主Demo,主要面向希望快速上手框架的客户端与服务器开发者,尤其适合作为ET框架的学习起点。项目内包含了完整的服务器端逻辑、Unity客户端工程、Proto网络协议定义、本地启动脚本、使用说明文档,以及编译输出的Bin和Build目录,从Actor消息驱动、网络通信、热更新到Unity界面渲染均有对应代码可供研读。通过研究该Demo,可以理解ET框架的分布式部署方式、基于Epoll与Kqueue的高性能网络通信、事件驱动模型和自动内存管理等核心特性,也能学会服务器与客户端的代码组织方式及热更新流程。包体共10487个文件,压缩包约81.99MB,主要文件类型包括C#脚本、动态链接库、Unity预制体、材质、图片和协议文件等,目录结构清晰完整。已有1133人学习下载,对于想要深入ET框架并二次开发游戏项目的开发者来说,是很有价值的参考资料。

1. ET框架 + 斗地主Demo:一套牌局代码跑通前后端有多省事

同样是写一个斗地主Demo,传统做法是客户端一套C#、服务端又一套逻辑,牌型判断这种核心代码得写两遍,改一次规则要同步两个工程。基于ET框架的做法恰好相反——服务端和客户端共用同一份牌局代码,本机拉开就是双端,改一处两边生效。听起来省事,实际动手后你会发现麻烦全藏在框架的约定里:实体、组件、事件、消息处理器,这些概念不搞清楚,Demo会卡在第一步。这篇笔记按一个可运行的斗地主Demo为主线,把牌局逻辑怎么组织、前后端消息怎么走、哪些地方最容易翻车,按我实际的落地顺序讲清楚,适合刚接触ET想拿棋牌Demo练手的人。

2. 把ET环境先跑起来:拉工程、起服务端、过登录

2.1 ET框架的分层:为什么服务端和客户端共用一套C#代码

ET框架最核心的设计是「项目即服务端、项目即客户端」。同一个Visual Studio解决方案里,同时存在引擎侧的客户端工程和纯服务端工程,两者都引用一份共享的逻辑代码目录。这套结构的价值在于:斗地主的洗牌、发牌、牌型判断、胜负判定是纯逻辑,不依赖任何界面表现,放进共享目录后,客户端本地校验和服务端权威校验调的是同一套函数,不会再出现「客户端说能出,服务端说非法」的尴尬。

理解ET的分层,我习惯按三个区域记:实体组件层(存放状态数据,比如手牌、桌号、身份)、逻辑事件层(处理玩家操作、AI托管、回合切换)、消息网络层(收发协议、广播)。写斗地主时最常碰的是第一和第三层。新手的典型误区是:上来就按传统MVC思路,把出牌逻辑写进UI按钮的点击事件里。这在ET里会寸步难行,因为服务端没有UI,那部分逻辑服务端根本执行不到。

另外要注意,ET的不同分支对环境和目录结构的约定不完全一样,有的用旧版热重载方案,有的是新版独立进程方案。我下面描述的是折中后的常见结构,你拉到的分支细节可能有出入,但「共享逻辑层 + 双端工程」这个骨架基本一致。

2.2 本机环境清单和工程结构对照

动手前先对照一下环境,缺一样后面都会以玄学报错的方式还回来。

项目建议配置用途说明
客户端引擎当前LTS分支的引擎版本跑客户端场景和UI表现
.NET SDK和框架分支要求的C#语言版本匹配编译服务端进程,版本高了低了都有兼容问题
IDERider或Visual Studio双端代码都在一个解决方案里,IDE的解决方案级搜索很重要
数据库Demo阶段可不装,用内存存储即可斗地主Demo的登录账号可以先写死在配置里
网络工具本机环回地址即可前后端同机联调,不需要跨网络

工程结构上,你会看到几个固定角色:引擎侧工程负责场景、UI、输入,它引用共享逻辑程序集;服务端工程是独立的控制台进程,不加载任何UI资源,只跑逻辑和网络;共享逻辑目录里放玩家实体、牌桌实体、牌型助手、消息定义。我一般会在共享目录里再按模块分文件夹:Card(牌型工具)、Room(桌子与回合管理)、Message(协议定义)、Handler(消息处理)。

我见过很多人卡在第一步:引擎工程打开了,但服务端进程没起来,客户端登录按钮点下去一直转圈。这不是框架问题,是「双端一体」这个心智没建立起来——客户端只是半个程序,服务端负责真正的状态权威。所以环境验证的第一件事,不是看界面,而是确认服务端进程正常监听端口。

2.3 用一段自检脚本确认登录链路是通的

环境搭好后,别急着写斗地主逻辑,先确认「客户端→服务端→回包→进场景」这条链路是通的。我习惯在引擎场景里挂一个自检脚本,登录成功后打印一条日志,这是最直接的探针。

public static class StartupCheck { public static async ETTask Run() { // 1. 先确认登录组件挂在了场景实体上,没有挂说明场景初始化顺序不对 LoginComponent login = Game.Scene.GetComponent<LoginComponent>(); if (login == null) { Log.Error("LoginComponent未挂载,先检查场景初始化流程"); return; } // 2. 指向本机服务端进程,端口要和服务端配置文件一致 login.ServerAddress = "127.0.0.1:10002"; login.Account = "demo_player"; login.Password = "123456"; // 3. 发起登录,成功后会触发EnterMap流程 await login.LoginAsync(); // 4. 监听进入地图场景的事件,作为链路通了的判定 Game.EventSystem.AddListener<EnterMapFinish>(() => { Log.Info("登录链路OK,已进入地图场景"); }); } }

这段代码的逻辑顺序是:先做前置依赖检查(登录组件在不在),再设置服务端地址和端口,然后发起异步登录,登录成功后的事件是整条链路的最终确认。参数里最容易错的是端口,ET服务端默认开放多个端口,登录端口和游戏逻辑端口往往不是同一个,以你服务端配置文件中AppSettings里的值为准。

顺手说一个验证技巧:登录成功后,你可以把服务端进程杀掉,然后让客户端发一条普通消息(比如心跳),看客户端是否在预期的时间内报「连接断开」。如果这里没反应,说明客户端网络层根本没和服务端建立长连接,后面写牌局广播时一定会出更隐蔽的问题。这一步确认了,再往下写斗地主心里才有底。

3. 斗地主牌局拆成实体和事件:洗牌、叫地主、出牌

3.1 用Entity装牌局状态:Player、Desk、Card这些对象怎么挂组件

ET里的Entity不完全是传统面向对象里的对象实例,它更像一个「带生命周期的挂载点」。一个玩家实体本身不存手牌,手牌是挂上去的组件;一个牌桌实体本身也不存轮次信息,轮次由TurnComponent管理。这样设计的好处是:同一个实体在不同阶段可以动态装卸能力,比如叫地主阶段给玩家实体挂上BidComponent,叫完地主就卸掉;而地主标记则加到另一个身份组件上,全程保留。

我设计的斗地主Demo实体关系:牌桌实体Desk是房间的根,它AddComponent出三个子实体座位0、1、2;每个座位实体上挂SeatComponent(座位号、在线状态、离线托管标记);玩家登录后,找到空座位,把自己的PlayerEntity塞进Seat;牌堆Deck是临时实体,发牌完直接回收。这样的好处是后续做旁观、断线重连时,座位和玩家可以解耦。

实体操作的常见写法如下:

// 创建牌桌 DeskEntity desk = EntityFactory.Create<DeskEntity>(Game.Scene, IdGenerater.GenerateId()); // 给牌桌挂回合管理组件 desk.AddComponent<TurnComponent>(); desk.AddComponent<CardDeskComponent>(); // 创建三个座位实体并挂到牌桌下 for (int i = 0; i < 3; i++) { SeatEntity seat = EntityFactory.Create<SeatEntity>(desk, IdGenerater.GenerateId()); seat.AddComponent<SeatComponent>().SeatIndex = i; seat.AddComponent<IdentityComponent>(); // 默认无身份 } // 玩家加入时,把玩家实体放到座位实体下 seat.AddChild(playerEntity);

注意这里我用了两种父子关系:Create(desk, ...)是给实体指定父节点,seat.AddChild(playerEntity)是后面动态挂载。在ET里,父实体释放时子实体会连带释放,这把生命周期的管理成本大大降低了。最直接的红利是:一把结束后,整个Desk实体连同所有玩家手牌、临时组件一起回收,不需要手写清理代码。

这里有一条血泪经验:组件不能重复挂。如果玩家断线重连时又执行了一次AddComponent,会直接报「组件已存在」。所以我在重连逻辑里先GetComponent,存在就直接用,不存在才Add。

3.2 牌型判断代码:单张、顺子、炸弹一个函数收掉

斗地主的业务核心,也就是这个Demo真正能落地的部分,是牌型判断。我在共享逻辑目录里放了一个纯静态类CardHelper,不依赖任何ET组件,方便双端共用和单元测试。牌面值我用整数编码:3~15对应3到2,16小王,17大王。

public enum CardType { None = 0, Single, Pair, Triple, TripleWithOne, TripleWithPair, Straight, StraightPair, Bomb, Rocket } public static class CardHelper { // 统计每张牌的数量,key是牌面值,value是张数 private static Dictionary<int, int> CountRank(IReadOnlyList<int> values) { var dict = new Dictionary<int, int>(); foreach (int v in values) { dict.TryGetValue(v, out int count); dict[v] = count + 1; } return dict; } public static CardType GetCardType(IReadOnlyList<int> values, out int mainRank) { mainRank = 0; if (values.Count == 0) return CardType.None; List<int> sorted = new List<int>(values); sorted.Sort(); // 王炸:只有大小王两张,且是全场最大的牌型 if (sorted.Count == 2 && sorted[0] == 16 && sorted[1] == 17) return CardType.Rocket; Dictionary<int, int> counts = CountRank(sorted); int uniqueRanks = counts.Count; if (sorted.Count == 1) { mainRank = sorted[0]; return CardType.Single; } if (sorted.Count == 2 && uniqueRanks == 1) { mainRank = sorted[0]; return CardType.Pair; } if (sorted.Count == 3 && uniqueRanks == 1) { mainRank = sorted[0]; return CardType.Triple; } // 炸弹:四张一样的牌,主牌就是这四张的大小 if (sorted.Count == 4 && uniqueRanks == 1) { mainRank = sorted[0]; return CardType.Bomb; } // 三带一:3张相同的牌加1张单牌 if (sorted.Count == 4 && uniqueRanks == 2) { int tripleRank = -1; foreach (var kv in counts) { if (kv.Value == 3) tripleRank = kv.Key; } if (tripleRank > 0) { mainRank = tripleRank; return CardType.TripleWithOne; } } // 顺子:至少5张连续单牌,2和王不能进顺子,所以最大牌面到14(即A) if (sorted.Count >= 5 && uniqueRanks == sorted.Count && sorted[^1] <= 14) { bool straight = true; for (int i = 1; i < sorted.Count; i++) { if (sorted[i] != sorted[i - 1] + 1) { straight = false; break; } } if (straight) { mainRank = sorted[^1]; return CardType.Straight; } } // 连对/双顺:至少3个连续对子,同样2和王不能进 if (sorted.Count >= 6 && sorted.Count % 2 == 0 && uniqueRanks == sorted.Count / 2) { bool allPair = true; foreach (var count in counts.Values) { if (count != 2) { allPair = false; break; } } if (allPair && sorted[^1] <= 14) { bool straightPair = true; int[] keys = counts.Keys.ToArray(); Array.Sort(keys); for (int i = 1; i < keys.Length; i++) { if (keys[i] != keys[i - 1] + 1) { straightPair = false; break; } } if (straightPair) { mainRank = keys[^1]; return CardType.StraightPair; } } } // 三带一可带任意一对同样合法:三带二 if (sorted.Count == 5 && uniqueRanks == 2) { int tripleRank = -1; foreach (var kv in counts) { if (kv.Value == 3) tripleRank = kv.Key; } if (tripleRank > 0) { mainRank = tripleRank; return CardType.TripleWithPair; } } return CardType.None; } }

这段代码的逻辑说明依次覆盖了斗地主最常见的九种牌型。判断顺序是「稀有度从高到低」:先判王炸,再判单张对子这类基本型,最后才判顺子连对这种需要遍历的组合型。mainRank是出牌比较的关键返回值,同类牌型比较只比它,不比张数,比如顺子34567和45678比较,前者主牌是7,后者是8,后者大。炸弹可以压所有非炸弹牌型,王炸压炸弹,这是接入出牌流程时由外部逻辑控制的,封装在另一个CanBeat函数里。

这个函数没有把飞机和四带二做全,是Demo的合理裁剪。等核心链路跑通,这几个类型按同样的分支思路补进去即可。要注意的是:顺子上限设为14,也就是A可以被包含在顺子里,但2、小王、大王都不行,这是斗地主规则里最容易写错的地方。

3.3 出牌轮转和回合推进:用状态机还是用事件

牌桌的流程控制,我建议用最简单的「阶段枚举 + 事件通知」,不要一上来就引入重型状态机框架。斗地主的流程其实只有三个大阶段:叫地主阶段(Bidding)、出牌阶段(Playing)、结算阶段(Settling)。每个阶段内的小状态用一个枚举字段表达足够了,比如Bidding阶段内部再分「等待0号选择」和「等待1号选择」。

ET的事件系统是这个流程的主力。阶段切换时发布一个事件,各组件监听后刷新自己的状态。代码结构大致如下:

public class TurnComponent : Entity { public int CurrentTurnSeat; // 当前轮到谁 public int PassCount; // 连续不要的人数,等于2时说明只剩一人出手 private int timerId; // 出牌超时定时器 public void StartTurn(int seatIndex) { CurrentTurnSeat = seatIndex; PassCount = 0; // 通知客户端:轮到谁出牌 MsgHelper.BroadcastSeat(GetParent<DeskEntity>(), new G2C_Turn { Seat = seatIndex }); // 10秒没操作就自动托管,避免Demo卡死 timerId = TimerComponent.NewFrameTimer(TimeSpan.FromSeconds(10), () => { AutoPlay(seatIndex); }); } public void OnPlayerPlayed(int seatIndex) { TimerComponent.Remove(timerId); // 推给下一家:真实逻辑要考虑底牌位置、跳过已经出完牌的玩家 StartTurn((seatIndex + 1) % 3); } private void AutoPlay(int seatIndex) { Log.Debug($"座位{seatIndex}超时未出牌,AI托管出牌"); // 调用AI策略出最小的合法牌 } }

这里的参数和签名是示意性的,核心是表达「事件驱动 + 定时器兜底」。斗地主联机Demo最容易遇到的一个局面是:断线玩家不出牌导致房间卡死。在开发前就考虑托管,是给以后省事的关键设计。我在项目里把托管分为两层:超时托管(一定时间没操作)和断线托管(会话断开立即触发)。两层共用同一个AI出牌策略函数。

你可能会问:为什么不直接把轮转逻辑写进消息Handler里?我的经验是:Handler只负责「收到出牌请求后做校验、扣牌、发布事件」,至于「下一个是谁」「要不要换阶段」这种事由TurnComponent管理。职责分开以后,AI托管、断线重连、人工操作都走同一套轮转入口,不会出现三个地方三个轮转逻辑。

4. 前后端打通:斗地主Demo的消息协议与出牌同步

4.1 消息方向对照:叫地主、出牌、托管分别走哪条链路

斗地主Demo的网络消息不需要很多,关键是每个阶段对应哪些消息要理清。我按「谁发起、谁响应、谁广播」三个维度做了张表,开发时对照着写,基本不会漏。

消息名方向作用触发时机
C2G_Login客户端→服务端登录鉴权客户端启动
G2C_LoginFinish服务端→客户端登录成功回执鉴权通过
C2G_EnterDesk客户端→服务端请求加入某个牌桌进入大厅后
G2C_DeskMembers服务端→房间广播同步当前房间所有成员有人出入房间
C2G_GrabLord客户端→服务端叫地主/不叫/抢地主叫地主阶段
G2C_GrabLordResult服务端→房间广播告知本轮叫分结果玩家做出选择
C2G_PlayCard客户端→服务端提交出牌/要不起出牌阶段
G2C_PlayCard服务端→房间广播广播出牌结果给所有人校验通过后
G2C_Turn服务端→房间广播通知当前轮到谁轮次切换
C2G_Pass客户端→服务端要不起出牌阶段

最初我只加了出牌和叫地主消息,结果联调时发现观战位客户端根本不刷新,就是漏了G2C_DeskMembers这条。消息不是越多越好,但每条必须能独立解释「哪个状态变化了、谁会受影响」。你写自己的消息时也按这个标准过一遍。

4.2 定义一条出牌消息:从字段命名到序列化注意点

消息定义是双端通信的地基,命名对不上或者字段顺序错位,表现就是「客户端出牌服务端没反应」。ET里的消息类通常继承MessageObject并配合序列化工具生成,我这边手写一个示意版,重点是字段怎么设计:

// 客户端 → 服务端:请求出牌 public class C2G_PlayCard : MessageObject { public long PlayerId; // 哪个玩家,服务端要根据Session再校验一次 public int[] Cards; // 牌的全局ID列表,不是牌面值,防止手牌外泄 public bool IsPass; // true = 要不起 } // 服务端 → 房间广播:出牌结果 public class G2C_PlayCard : MessageObject { public int Seat; // 座位号 public int[] Cards; // 本手出的牌(要不起时为空数组) public int CardType; // 服务端判定后的牌型,序列化后是个int public int MainRank; // 主牌大小,客户端直接用于比较 public bool IsPass; // 是否是要不起 public int RemainCount; // 该玩家剩余手牌数,用于显示和判断结束 }

这里有几个字段设计时的实际考量。Cards字段传的是牌ID而非牌面值,是因为服务端要做合法性校验,如果客户端直接传牌面值,就相当于客户端能伪造手牌,这是单机玩法移植到联机时最容易留的安全口子。卡牌ID到牌面值的映射只存在于服务端的洗牌组件里,客户端提交出牌后,服务端拿这些ID去查自己维护的牌映射表,得到牌面值再做牌型判断。

按ET的消息命名习惯,C2G前缀是Client to Gate,G2C是Gate to Client。有的分支支持Actor消息,支持的话可以用ActorLocationSend直接把消息发给指定玩家实体,但Demo场景用普通消息+房间广播足够了,架构负担更小。字段顺序也要注意:如果协议不走自动生成而是手动序列化,两端的字段声明顺序必须完全一致,这是老手也会踩的坑。

4.3 服务器收到C2G_PlayCard之后做了什么

Handler是ET里处理客户端消息的统一入口。我写的出牌Handler主要负责四件事:会话校验、轮次校验、牌型和大小校验、状态更新与广播。下面这段是去掉了分支差异的通用结构:

public class C2G_PlayCardHandler : AMHandler<C2G_PlayCard> { protected override async ETTask Run(Session session, C2G_PlayCard msg) { // 1. 从Session找到自己绑定的玩家实体 PlayerEntity player = session.GetComponent<PlayerSessionComponent>().Player; if (player == null) { // 会话没绑定玩家说明没登录,直接拒绝 await session.Send(new G2C_Error { Code = ErrorCode.NotLogin }); return; } // 2. 通过玩家找到牌桌实体和回合组件 DeskEntity desk = player.GetParent<DeskEntity>(); TurnComponent turn = desk.GetComponent<TurnComponent>(); // 3. 轮次校验:不是自己的回合就不能出牌 if (turn.CurrentTurnSeat != player.SeatIndex) { await session.Send(new G2C_Error { Code = ErrorCode.NotYourTurn }); return; } if (!msg.IsPass) { // 4. 校验牌有没有:服务端维护的手牌里必须包含提交的每一张牌 if (!player.RemoveCards(msg.Cards)) { await session.Send(new G2C_Error { Code = ErrorCode.CardNotEnough }); return; } // 5. 牌型判断 + 和上一手比较大小 CardType type = CardHelper.GetCardType(msg.Cards, out int mainRank); if (type == CardType.None || !CardHelper.CanBeat(type, mainRank, desk.LastCardType, desk.LastMainRank)) { // 校验失败要把刚才扣掉的牌还回去 player.AddCards(msg.Cards); await session.Send(new G2C_Error { Code = ErrorCode.IllegalPlay }); return; } // 6. 更新牌桌记录的上一手信息 desk.LastCardType = type; desk.LastMainRank = mainRank; desk.LastPlayerSeat = player.SeatIndex; // 7. 广播给房间内所有人(包括出牌者自己) MsgHelper.Broadcast(desk, new G2C_PlayCard { Seat = player.SeatIndex, Cards = msg.Cards, CardType = (int)type, MainRank = mainRank, RemainCount = player.HandCount }); } else { // 要不起逻辑:只广播Pass,不改变上一手记录 MsgHelper.Broadcast(desk, new G2C_PlayCard { Seat = player.SeatIndex, IsPass = true }); } // 8. 检查手牌是否出完,出完则触发结算流程 if (player.HandCount == 0) { await Game.EventSystem.Publish(new DeskFinishEvent { Desk = desk }); return; } // 9. 轮转到下一家 turn.OnPlayerPlayed(player.SeatIndex); } }

参数说明几点。整个Handler不直接操作界面,所有逻辑都发生在服务端内存里,客户端收到广播后自己刷新表现层。RemoveCards返回bool,是因为服务端牌序用List管理,移除失败说明客户端提交的牌ID不合法或者重复,这种错误要当场拒绝。第六步里desk.LastCardType的记录是整局共享的,它决定了「要不起」之后下一家的出牌基准是自由出牌还是必须压上一手。

第三步和第五步的顺序是我想重点强调的:先做轮次校验再做牌型校验。如果反过来,一个越权玩家可以先试出自己的牌型,服务端在逻辑上就把未轮到的玩家的手牌信息暴露给了非法请求方,属于逻辑漏洞。

5. 避坑指南:ET斗地主Demo最常见的5个坑与排查顺序

5.1 客户端改了业务逻辑,服务端行为还是旧版

现象:在共享逻辑目录里改了牌型判断规则,引擎客户端运行已经生效,但服务端进程启动后表现还是旧逻辑。

原因:ET的共享逻辑会被分别编译进两个程序集:一份给引擎侧加载,一份给服务端进程加载。你改了源文件,如果服务端没有触发重新编译,或者服务端进程没有重启加载新的程序集,它跑的就是上一次的旧代码。这个坑在整个ET开发周期里会出现无数次。

解决:改完共享目录代码,第一步重新编译整个解决方案,确认服务端工程的输出目录时间戳更新了,再启动服务端进程。我在本地专门做了一个「编译后自动复制程序集到服务端运行目录」的批处理,把那一步固化下来,就不会忘记了。

5.2 事件注册了却一直不回调

现象:代码里写的监听都注册了,日志也没报错,但事件就是触发不起来。

原因:ET的事件系统依赖实体的生命周期。监听者如果挂在一个已经被回收的临时实体上,等事件发布时目标已经不在了;还有另一种情况是AddListener写在了组件初始化之前,发布时监听还没注册上。这两个原因在斗地主Demo里都常见,尤其是断线重连时重建玩家实体,旧实体上的监听被回收,新实体没注册。

解决:排查时先打两行日志,一行在AddListener之后,一行在事件发布函数里,看哪个没打印。确认都执行后,检查监听者实体的IsDisposed状态是否为true。我的习惯是把长生命周期的监听放在牌桌实体上,不放在玩家实体上,牌桌不销毁监听就不丢。

5.3 出牌后其他玩家画面不同步

现象:自己客户端出牌显示正常,旁边另一个玩家客户端什么都收不到,或者收到的牌型不对。

原因:广播范围错了。最常见的写法是把结果发回给请求方Session,而不是发给牌桌内的所有成员。如果房间内玩家的Session没有全部加入牌桌的会话列表,后面进房间的人也会漏消息。

解决:统一走MsgHelper.Broadcast(desk, msg)这类按实体聚合的发送函数,轮询Desk下所有Seat子实体,取每个座位的Session发送。加新玩家进房间时,必须把当前牌局的全量状态(谁的地主、上一手牌是什么、当前轮到谁)作为快照发给他,而不是只广播增量消息。快照同步和增量同步是两件事,Demo里都要做。

5.4 引擎工程升级后编译报错成片

现象:换了个引擎版本打开工程,哗啦啦几十条编译错误,大多指向API已经不存在。

原因:ET框架对引擎版本和C#语言版本绑定得很紧。有些分支用到了一些实验性API,在引擎升级后这些API改名或者删除了;反过来,新分支也可能用到旧引擎不支持的语言特性。这类报错不是你的代码写错了,是框架和引擎的版本配对出了问题。

解决:不要动框架的API去适配新引擎版本。正确做法是:先查你拉的这个框架分支说明,确认它对应的引擎版本号,然后把这个版本锁死。换版本不如重拉分支,重拉分支不如锁版本。我当初为了「尝鲜」把引擎升了一个大版本,花了整整一天修报错,最后回滚,纯属浪费时间。

5.5 牌型判断结果和手牌对不上

现象:客户端显示能出的牌,服务端返回IllegalPlay;或者反过来,服务端判断合法,客户端UI认为非法。

原因:两端加载的程序集版本不一致,或者是牌面编码映射错了。更隐蔽的是序数问题:CardType枚举在两端的数值序号不一样,序列化传过去的是int,但两边的枚举定义顺序不同,导致3在A端是Bomb,在B端是Triple。

解决:第一步清掉本地缓存重新编译,排除版本陈旧问题;第二步在服务端启动时打印一份CardType枚举的序号日志,客户端也打一份,对比两边序号。只要出现端到端的牌型不一致,先查枚举序号而不是查判断逻辑——这个排查顺序能省掉大半的「玄学问题」。

6. 从跑通到能见人:验证牌局逻辑的日志审计法与三个进阶方向

先聊验证方法。斗地主Demo跑起来容易,跑对难。我强烈建议在牌桌实体上增加一个审计日志开关,把每一手出牌的关键信息打印到统一日志文件里。

// 审计协议:只在这个开关打开时打印 if (Game.Scene.GetComponent<DebugConfigComponent>().EnableDeskAudit) { Game.Log.Info($"[审计] 桌{desk.Id} 座位{player.SeatIndex} " + $"出[{string.Join(",", msg.Cards)}] " + $"池[{string.Join(",", desk.LastCards)}] 轮次{turn.CurrentTurnSeat} " + $"剩余{player.HandCount}"); }

审计开启后,连续跑二十把牌局,然后把日志拉出来做三段校验:牌型合法性(每手牌调用CardHelper能判定出非None牌型)、数量守恒(每个玩家初始手牌17张、地主20张,减去累计出牌和剩余手牌恒等于0)、轮次序列(每局轮流顺序严格0→1→2循环,结算前不跳位)。这三条全部通过,才能说牌局核心逻辑是稳的。

进阶方面,第一个值得做的是AI托管。很多人把托管做成「随便出一张最小的牌」,实际体验很差。正确做法是复用CardHelper的牌型分组结果,生成当前可出的所有合法牌组,按「单牌优先、对子其次、拆炸弹滞后」的策略选择。这个AI虽然很初级,但能让你的Demo在单机环境下完整跑完一整局,调试效率高一个量级。

第二个方向是断线重连。架构上你已经在座位实体上挂好了PlayerEntity,重连时只需要:根据客户端传来的PlayerId找到旧实体,卸掉旧Session组件重新绑定新Session,然后发一包全量快照消息,包含手牌、身份、当前桌上牌、轮次剩余时间。很多Demo不做这个,但只要你经历过一次「打到最后两张牌断线重来」,就知道这个功能值多少投入。

第三个方向是玩法变体。斗地主规则衍生场景非常多:癞子斗地主、不洗牌模式、跑得快。这些变体的改动点高度集中:癞子只改牌型判断的预处理,不洗牌只改洗牌组件,跑得快只需要删除叫地主阶段和底牌逻辑。你在Demo阶段把牌局逻辑和服务端状态做得足够干净,扩展这些玩法大概只要一两天。

我印象最深的一次翻车经历,是把一张桌子的斗地主Demo发给朋友联机试玩,五把牌出现三次「我明明出的是顺子,对面看到的是拆开的单牌」。查到最后,原因是客户端和服务端加载的CardType枚举序号因为一次手工合并冲突对不上了。那次之后我养成了一个习惯:所有双端共用的枚举类型,必须有一份自动生成的序号校验日志放在双方启动流程里。单机Demo不会暴露这类问题,一旦联机就会变成最难查的黑匣子。按时校验双端一致性,比任何框架技巧都值得做。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询