基于ET框架的斗地主Demo开发实践与踩坑指南
发布时间:2026/9/26 22:58:05来源:尧图网络
简介基于ET框架的斗地主Demo是一份面向游戏开发初学者的ET框架实践示例旨在帮助快速掌握ET4.0版本的核心用法。资源包共10487个文件以C#脚本、meta、info、bin、dll、xml及png等类型为主涵盖服务器端代码、Unity客户端工程、Proto协议定义、编译输出与构建产物整体约81.99MB压缩包为7z格式。通过本地.bat和使用说明.txt可以快速运行项目结合Output、Build等目录还能了解编译与打包流程。已有1132人下载学习。该Demo不仅展示了ET框架的分布式架构、高性能网络库与热更新机制还提供了完整的斗地主玩法代码。其中Server目录适合研究游戏逻辑与网络处理Unity目录用于客户端界面开发Proto目录帮助理解协议定义。通过该Demo新手可以快速了解ET框架的整体结构掌握服务器与客户端代码的组织方式理解网络通信与热更新流程并熟悉Unity中游戏界面的设计思路。代码结构清晰对于希望从零搭建ET框架项目的开发者这份Demo提供了可直接运行的参考是理论与实践结合的入门佳选。1. 基于ET框架的斗地主Demo先跑通一桌牌再谈框架选型斗地主这种牌局在牌类项目里属于“看着简单、写起来全是细节”的活儿54张牌的组合判断、叫地主的三分制、顺子炸弹的压牌规则任何一条没定义清楚对局就会在第三轮出牌时突然翻车。基于ET框架的斗地主Demo就是把ET框架这套以实体组件为核心、自带消息分发和服务端热更的C#游戏框架用来做一桌能完整跑起来的斗地主牌局。它的价值在于验证房间类游戏最关键的三件事消息触发房间状态流转是否顺滑、服务端热更能不能支撑牌局逻辑快速迭代、多人同时出牌时状态是否一致。这篇笔记适合第一次碰ET框架、又想以牌局练手的开发者也适合客户端程序员想顺着Demo理解服务端消息链路、真实跑通一次。下面按框架原理、本地环境、核心逻辑、避坑、验证来展开。2. ET框架的分层架构与消息流转斗地主Demo为什么适合这样搭在写任何代码之前先把ET框架里“实体、组件、事件、消息”四个概念和斗地主的对应关系理清楚。很多人在ET上做第一个项目时容易把服务端写成一堆静态类加单例那等于绕开了框架的核心价值后面做房间管理和热更都会很别扭。2.1 实体-组件与事件系统框架的“骨”和“筋”ET框架里最基础的单位是Entity它不是一个具体的业务对象而是一个空壳。数据住在Component里逻辑写在System或者Handler里。对应到斗地主Demo一张桌子、一个玩家、一场对局都是Entity桌面上的公共状态和某个玩家的私有手牌则分别是不同的Component。常见做法是这样定义两块数据// Model层只放数据定义不写玩法逻辑 public class DeskComponent : Entity { public int RoomId; public int CurrentSeat; // 当前操作座位 public int LandlordSeat -1; // 地主座位-1表示还没叫完 public int BidScore; // 当前最高叫分 public int MaxBidSeat -1; // 当前最高分的座位 public Listint DeckCards new(); // 三张底牌 public Listint LastCards new(); // 上一手打出的牌 public int LastPlaySeat -1; // 上一手出牌人 } public class PlayerComponent : Entity { public int SeatIndex; public Listint HandCards new(); public bool IsLandlord; public bool IsOnline; }把RoomId、CurrentSeat这些字段放在DeskComponent里是因为它们是三个玩家共享的状态任何一个人出牌都会改变CurrentSeat而HandCards只属于单个玩家拆开放更合理。ET 6.0之后统一了实体和组件的继承关系都叫Entity用AddComponent挂载即可所以上面两个类直接继承Entity也没有问题按你用的分支来。事件系统则是用来解耦的。比如玩家进入桌子后要触发“通知其他玩家”、“推送手牌”、“记录日志”三件事如果用同步调用写在一起后面每加一个动作都要动原本的方法。我一般会用ET的事件发布来解耦// 事件定义只携带上下文 public struct PlayerEnterDeskEvent { public DeskComponent Desk; public PlayerComponent Player; } // 在进入房间的Handler里发布 await EventSystem.PublishAsync(new PlayerEnterDeskEvent { Desk desk, Player player });订阅方各自处理自己关心的事别人订阅不订阅互不影响。对斗地主这种回合明确、事件种类少的项目事件系统能让你把“出牌”和“出牌之后的推送”拆开调试时只盯一个环节。2.2 一条出牌消息的完整旅程从客户端按钮到服务端广播斗地主Demo里最典型的链路是出牌。客户端三个玩家其中一个点击“出牌”选了几张牌点确定之后发生的事情可以拆成六个步骤。客户端构造消息并发送// 客户端把选中的牌封装成消息 var request new C2G_PlayCard { RoomId 1001, Cards selectedCards.ToList() // Listint牌ID列表 }; var response (G2C_PlayCard)await session.Call(request);这里我故意用Call而不是Send。ET框架里Send是只发不等回复适合不关心结果的广播Call是请求-响应模型会等待服务端返回。出牌这个动作必须知道服务端是否认可因为服务端要校验牌型、校验是否轮到自己校验失败客户端要立刻回滚手牌状态。服务端收到消息后消息分发器会根据消息Opcode找到对应的Handler[MessageHandler] public class C2G_PlayCardHandler : AMHandlerC2G_PlayCard { protected override async ETTask Run(Session session, C2G_PlayCard message) { // 从Session上取玩家信息这是Gate层绑好的 var user session.GetComponentSessionUserComponent(); if (user null) { session.Send(new G2C_PlayCard { Error ErrorCode.ERR_NeedLogin }); return; } // 通过玩家ID找到所在房间 var room RoomManagerComponent.Instance.GetRoom(user.PlayerId); if (room null) { session.Send(new G2C_PlayCard { Error ErrorCode.ERR_RoomNotExist }); return; } var desk room.GetComponentDeskComponent(); var result CardLogic.TryPlay(desk, user.PlayerId, message.Cards); // 广播给其他玩家 if (result.Success) { var broadcast new S2C_PlayCard { SeatIndex result.SeatIndex, Cards message.Cards, RemainCount result.RemainCount }; room.Broadcast(broadcast); } else { session.Send(new G2C_PlayCard { Error result.ErrorCode }); } } }这段Handler体现了ET框架两个特点一是Session作为连接载体可以在上面挂用户信息组件二是业务逻辑不写在Handler里而是放在CardLogic这种静态工具类或房间实体的System里Handler只负责消息路由和响应。这样热更时只换逻辑DLL消息定义和实体定义不动。2.3 选ET还是原生Socket/Mirror三组对比很多第一次做牌局项目的人会问不用ET用原生Socket加JSON协议行不行或者用Mirror这种Unity同步库行不行我直接给结论都能做但后期成本不一样。方案服务端承载热更能力消息分发适合场景原生Socket 自写路由自己实现线程模型无自己写教学、初级DemoMirror / UNET主从同步为主弱组件同步合作类、小规模联机ET框架多进程分布式支持Hotfix热更Handler Opcode房间类、MMO、牌局原生Socket做斗地主要处理的粘包拆包、心跳、重连、消息序号每一项都需要自己写写三个月后你得到的只是一个能跑通的通信库牌局逻辑还没开始。用Mirror这类同步库它擅长的Transform同步在牌局里用处不大斗地主是低频消息、高频状态校验消息同步比状态同步更可控。ET框架的好处是消息系统、Actor模型、热更框架都已经是现成的你把注意力放在牌局规则上就行。这不是玄学而是成本账。Demo阶段也许感觉不明显等你开始加机器人、加录像回放、加好友围观时ET的消息分发和组件化拆分优势就开始体现了。3. 用ET框架在本地跑通斗地主Demo环境准备与最小房间理解了架构之后下一步是让Demo在本地跑起来。这一章的每一步都对应一个命令行或代码动作按顺序执行完你应该能看到一个空房间被创建然后一个玩家加进去。3.1 准备工程和启动服务端Unity项目、.NET工程与进程参数ET框架的服务端是.NET控制台工程客户端是Unity工程两者共用Model和Hotfix代码。先从官方仓库拉取代码切到和你Unity版本匹配的分支git clone ET框架官方仓库地址 ET cd ET git checkout 6.0 # 按你的Unity版本选择分支 dotnet build Server/Server.sln构建成功后服务端按进程启动。ET框架默认有一套多进程配置我一般把Realm、Gate、Map拆开跑方便定位问题# 终端1负责登录验证 dotnet run --project Server/Apps/Server.App -- --AppTypeRealm --Process1 # 终端2负责客户端连接和转发 dotnet run --project Server/Apps/Server.App -- --AppTypeGate --Process1 # 终端3负责房间和牌局逻辑 dotnet run --project Server/Apps/Server.App -- --AppTypeMap --Process1这三个进程参数里AppType决定了这个进程扮演的角色。Realm管账号登录Gate管客户端连接Map管房间逻辑。斗地主Demo的牌桌就创建在Map进程上。有的分支支持用--AppTypeAll单进程启动适合本地快速调试但我不建议一开始就用因为你看不到每个进程的日志边界。然后打开Unity工程找到Init场景。ET框架的Init场景一般会有一个InitComponent里面配置服务器IP和端口。把这些指到本机端口和Gate进程监听的一致。运行Unity工程看到“连接成功”日志后服务端与客户端链路就通了。3.2 创建房间与加入用一条Call消息打通客户端到服务端链路通了之后第一个功能是创建房间。客户端点按钮发一条创建房间请求服务端创建一个Room实体并挂上DeskComponent然后返回房间号[MessageHandler] public class C2G_CreateRoomHandler : AMHandlerC2G_CreateRoom { protected override async ETTask Run(Session session, C2G_CreateRoom message) { var user session.GetComponentSessionUserComponent(); if (user null) { session.Send(new G2C_CreateRoom { Error ErrorCode.ERR_NeedLogin }); return; } // 在Map场景下创建房间实体 var room EntityFactory.CreateRoomEntity(Game.Scene); room.AddComponentDeskComponent(); // 创建者默认坐在0号位 var player room.AddComponentPlayerComponent(); player.SeatIndex 0; player.HandCards.Clear(); player.IsOnline true; // 返回房间号给客户端 session.Send(new G2C_CreateRoom { RoomId room.Id, Error ErrorCode.ERR_Success }); Log.Info($房间创建成功: {room.Id}); } }这里注意RoomEntity和PlayerComponent都创建在Game.Scene下RoomEntity作为房间的根实体DeskComponent和玩家组件都挂在这棵实体树上。这样房间销毁时所有子组件一起释放不会留下孤儿实体。客户端收到房间号后可以把UI切到牌桌场景等待其他玩家加入。为了让调试更直观我一般会在服务端加一个模拟加入的接口方便单人调试// 调试接口让一个假玩家加入房间 [MessageHandler] public class C2G_DebugJoinRoomHandler : AMHandlerC2G_DebugJoinRoom { protected override async ETTask Run(Session session, C2G_DebugJoinRoom message) { var room RoomManagerComponent.Instance.GetRoom(message.RoomId); var player room.AddComponentPlayerComponent(); player.SeatIndex room.GetComponentDeskComponent().NextSeatIndex(); player.IsOnline false; room.Broadcast(new S2C_PlayerEnter { SeatIndex player.SeatIndex }); } }加入房间的逻辑和创建基本一致只是要把玩家角色放到RoomEntity下同时更新DeskComponent里的座位状态。ET框架里座位管理就是一个简单的整数轮转public int NextSeatIndex() { // 找第一个空的座位 var desk this.GetComponentDeskComponent(); for (int i 0; i 3; i) { if (!desk.SeatOccupied.ContainsKey(i)) { desk.SeatOccupied[i] true; return i; } } return -1; // 房间满了 }3.3 自测与日志如何判断链路真的通了跑Demo最怕的是“看起来啥都没发生”。我一般通过三条日志确认链路第一服务端Gate进程出现“客户端连接成功”的日志说明TCP连接建立了。第二客户端发起登录后Realm进程出现“验证成功”日志说明消息路由到了Realm。第三创建房间后Map进程出现“房间创建成功”日志同时客户端界面跳转到了牌桌场景。如果卡在某一步最常见的检查点消息Opcode是否正确注册、客户端连接的端口是不是Gate进程监听的端口、服务端各个进程是否用了同一个StartConfig配置。这三项只要有一项不对消息就不通。4. 斗地主Demo的牌局核心发牌、叫地主、出牌判断与回收重置网络链路跑通后真正的技术活是牌局逻辑。这一章不讲UI只讲服务端的状态机、牌型判断和校验逻辑。UI只是把服务端的状态渲染出来。4.1 牌型编码与发牌54张牌怎么变成三份手牌和底牌斗地主用54张牌我用整数做牌ID0到51表示四种花色的3到252是小王53是大王。牌ID只需要唯一性真正比较大小要用“牌面值”也就是3到15对应扑克牌的3到A15是216是小王17是大王。public static class DeckHelper { public const int SMALL_JOKER 52; public const int BIG_JOKER 53; // 生成完整牌堆 public static Listint CreateDeck() { var deck new Listint(54); for (int i 0; i 52; i) deck.Add(i); deck.Add(SMALL_JOKER); deck.Add(BIG_JOKER); return deck; } public static void Shuffle(Listint deck) { var random new Random(); for (int i deck.Count - 1; i 0; i--) { int j random.Next(i 1); (deck[i], deck[j]) (deck[j], deck[i]); } } // 从牌ID取牌面值用于后续比较 public static int GetCardValue(int cardId) { if (cardId SMALL_JOKER) return 16; if (cardId BIG_JOKER) return 17; return cardId / 4 3; } }发牌逻辑按座位轮转每人每次拿一张拿17轮最后剩3张做底牌public static void Deal(DeskComponent desk, ListPlayerComponent players) { var deck CreateDeck(); Shuffle(deck); for (int i 0; i 17; i) { for (int p 0; p players.Count; p) { players[p].HandCards.Add(deck[i * players.Count p]); } } // 最后3张是底牌 for (int i 51; i 54; i) { desk.DeckCards.Add(deck[i]); } }这样发牌的好处是每个玩家按轮次各拿一张手牌自然分散避免某个玩家一口气拿到一堆大牌导致牌序差异明显。4.2 叫地主流程用轮转状态机控制三个玩家的回合叫地主在斗地主里有两种常见规则叫分制和抢地主制。Demo阶段我建议先实现叫分制逻辑更清晰。三个玩家轮流叫1分、2分、3分或者不叫后叫的人必须比当前最高分大叫到3分直接结束三家都不叫就重新发牌。先把状态定义清楚public enum DeskState { None 0, // 等待开始 Dealing 1, // 发牌中 Bidding 2, // 叫地主 Playing 3, // 出牌 Settled 4 // 结算 }叫地主的流程可以写成一个纯函数服务端Handler只负责把客户端传来的叫分交给它public static class BidLogic { public static void OnBid(DeskComponent desk, int seat, int bidScore) { // 非法输入不是当前轮到的人或叫分不大于当前最高分 if (seat ! desk.CurrentSeat || bidScore 0 || bidScore 3) { return; } if (bidScore 0) { // 不叫轮到下一个人 desk.CurrentSeat (desk.CurrentSeat 1) % 3; return; } if (bidScore desk.BidScore) { desk.BidScore bidScore; desk.MaxBidSeat seat; } // 叫到3分或者已经轮完一圈结束叫地主 if (desk.BidScore 3 || IsRoundFinished(desk)) { SetLandlord(desk, desk.MaxBidSeat); } else { desk.CurrentSeat (desk.CurrentSeat 1) % 3; } } private static bool IsRoundFinished(DeskComponent desk) { // 三个玩家都叫过或不叫 return desk.LastBidSeat ! -1 (desk.LastBidSeat 1) % 3 desk.CurrentSeat; } private static void SetLandlord(DeskComponent desk, int seat) { desk.LandlordSeat seat; desk.CurrentSeat seat; desk.State DeskState.Playing; // 底牌发给地主 var player desk.GetPlayer(seat); player.HandCards.AddRange(desk.DeckCards); desk.DeckCards.Clear(); } }这里要注意判断“本轮结束”不能光看叫分因为可能出现两家不叫、一家叫1分的情况还得再轮一次才能确认。用LastBidSeat记录最后一个叫分的人轮转回来就结束更准确。4.3 出牌合法性判断牌型识别与比较函数出牌判断是整个Demo最核心的部分也是新手最容易翻车的地方。先把牌型枚举列出来public enum CardType { None 0, Single 1, // 单张 Pair 2, // 对子 Three 3, // 三张 ThreeOne 4, // 三带一 ThreePair 5, // 三带二 Straight 6, // 顺子 StraightPair 7, // 连对 Plane 8, // 飞机不带 PlaneSingle 9, // 飞机带单 Bomb 10, // 炸弹 Rocket 11 // 王炸 }判断牌型的核心思路先统计每种牌面值出现的次数再根据次数分布和数量判断public static CardType GetCardType(Listint cards) { if (cards null || cards.Count 0) return CardType.None; int n cards.Count; // 统计每个牌面值的张数 var countDict new Dictionaryint, int(); foreach (int id in cards) { int v DeckHelper.GetCardValue(id); countDict[v] countDict.GetValueOrDefault(v) 1; } var values countDict.Keys.OrderBy(x x).ToList(); var groups countDict.Values.OrderBy(x x).ToList(); int distinct values.Count; int maxCount countDict.Values.Max(); if (n 1) return CardType.Single; if (n 2) { // 王炸大小王各一张 if (countDict.ContainsKey(16) countDict.ContainsKey(17)) return CardType.Rocket; if (maxCount 2) return CardType.Pair; return CardType.None; } if (n 3 maxCount 3) return CardType.Three; if (n 4 maxCount 4) return CardType.Bomb; if (n 4 maxCount 3) return CardType.ThreeOne; if (n 5 distinct 2 maxCount 3) return CardType.ThreePair; if (n 5 maxCount 1 values.Max() 15 IsContinuous(values)) return CardType.Straight; if (n 6 maxCount 2 distinct n / 2 values.Max() 15 IsContinuous(values)) return CardType.StraightPair; // 飞机识别略按“连续的三张”数量判断 if (n 6 maxCount 3) { var threeValues countDict.Where(kv kv.Value 3).Select(kv kv.Key).OrderBy(x x).ToList(); if (threeValues.Count 2 IsContinuous(threeValues)) return n threeValues.Count * 3 ? CardType.Plane : CardType.PlaneSingle; } return CardType.None; } private static bool IsContinuous(Listint values) { for (int i 1; i values.Count; i) { if (values[i] ! values[i - 1] 1) return false; } return true; }判断能不能压过上家规则是王炸最大任何炸弹压过非炸弹同类型牌比主牌大小其他情况不能压public static bool CanBeat(CardType lastType, int lastMainValue, CardType curType, int curMainValue) { if (curType CardType.Rocket) return true; if (lastType CardType.Rocket) return false; if (curType CardType.Bomb) { if (lastType ! CardType.Bomb) return true; return curMainValue lastMainValue; } if (curType ! lastType) return false; return curMainValue lastMainValue; }这里的主牌值取牌型里最大的牌面值。顺子取最大值三带一取三张的值炸弹取四张的值。这个函数写完后要单独做一遍完整的测试后面第6章会讲验证方法。4.4 一局结束胜负判定与房间重置一局牌结束的标志是某个玩家手牌出完。服务端在每次出牌成功后检查出牌者剩余手牌数public static void OnPlayCard(DeskComponent desk, int seat, Listint cards) { var player desk.GetPlayer(seat); // 校验轮次和牌型通过后从手牌中移除 foreach (int id in cards) { player.HandCards.Remove(id); } desk.LastCards cards; desk.LastPlaySeat seat; if (player.HandCards.Count 0) { // 一局结束进入结算状态 desk.State DeskState.Settled; desk.WinnerSeat seat; // 广播S2C_GameFinish return; } // 下家出牌如果上家出了牌下家必须跟或过 if (desk.PassCount 2) { desk.LastCards null; desk.LastPlaySeat -1; desk.PassCount 0; } desk.CurrentSeat (seat 1) % 3; }结算后要重置房间。很多Demo在这里偷懒直接把房间销毁、让玩家重新建房但这会给客户端带来不必要的跳转。我一般是把DeskComponent里的数据全部清空保留RoomEntity进入“等待下一局”状态public static void ResetDesk(DeskComponent desk) { desk.State DeskState.None; desk.LandlordSeat -1; desk.BidScore 0; desk.MaxBidSeat -1; desk.DeckCards.Clear(); desk.LastCards.Clear(); desk.LastPlaySeat -1; desk.CurrentSeat 0; desk.PassCount 0; // 清掉所有玩家的手牌 foreach (var player in desk.GetPlayers()) { player.HandCards.Clear(); player.IsLandlord false; } }重置不等于销毁这是两个概念。销毁房间会导致所有玩家断线重置则让同一批玩家无缝开始下一局体验完全不同。5. 避坑ET框架写斗地主的5个典型踩坑记录这一章记录的都是在ET框架上做牌局项目最常见的坑。每一条都是“现象、原因、解决”三段式基本覆盖了从热更、消息路由到并发和资源清理的问题。5.1 改了Hotfix逻辑却不生效现象改完出牌判断逻辑Unity客户端重新运行服务端也重启了但打出的牌还是按旧规则校验。查代码发现新逻辑已经在工程里运行的就是不对。原因ET框架的服务端热更逻辑编译后要拷贝到服务器进程目录很多分支在构建时不会自动同步。Unity侧编辑的是Hotfix工程运行的是服务器目录下的一份DLL拷贝。两边的文件不一致改的是源工程跑的是旧DLL。解决每次改完Hotfix代码构建后确认服务器目录下的Hotfix.dll时间戳是否更新。我一般会写一个批处理脚本把构建产物复制到服务器运行目录再启动进程。排查时先看文件时间再谈其他这一步能排除一半的玄学问题。5.2 消息Opcode重复导致Handler串线现象客户端发的是“出牌”消息服务端却执行了“叫地主”的Handler。整个流程没报错但状态流转完全不对。原因ET框架用整型Opcode作为消息的唯一标识新加消息时如果复制了已有消息定义忘记改Opcode编号两条消息就共用同一个Opcode。分发器按Opcode找Handler第一条查到的Handler就被执行了。解决给消息编号按模块分段比如房间类消息从10001开始牌局类消息从20001开始登录类从30001开始。新加消息时先查一遍现有Opcode而不是顺眼就复制。框架本身有一些工具能检查重复但我还是建议自己维护一个消息清单新消息在清单里登记编号。5.3 客户端和服务端手牌状态不一致现象客户端显示玩家A手上还有5张牌服务端记录只有4张。再出几次两边手牌数量差得越来越多最终某次出牌被服务端拒绝玩家卡死。原因客户端在发送出牌请求后立即做了本地手牌移除而没有等服务端确认。服务端校验失败时客户端没有收到失败回执或者收到了但没有执行回滚。解决客户端必须先出牌请求等服务端返回成功后才更新本地手牌。我在Demo里统一用Call而不是Send服务端返回成功或失败客户端只信响应。这会有一次RTT的延迟但牌局游戏对极低延迟不敏感一致性优先级更高。5.4 多线程下共用List导致牌序错乱现象服务端偶尔出现手牌列表里多了一张、少了一张或者出牌顺序偶发混乱。不频繁但一出现就导致整局无法继续。原因ET框架底层会起多个线程处理网络消息如果两个消息Handler同时操作同一个DeskComponent或PlayerComponent里的List就会产生竞态。比如“出牌”和“超时自动出牌”两个消息同时到达一个在读List一个在Remove。解决共享状态统一回主逻辑线程操作。ET框架提供了同步上下文机制在Handler里用异步调度把真正修改房间数据的代码切回主线程避免多个线程交叉访问。具体做法是把实际逻辑放到Room实体的System里由房间Actor串行处理消息只是一个触发信号。Actor模型天然串行处理同一实体的消息这是ET框架处理房间类玩法的标准手段。如果你用裸List当共享状态又不开Actor早晚出问题。5.5 房间Actor消息没清理现象新开的第二局某玩家手牌里出现了上一局的残牌或者上上局的出牌广播在新一局开始后还在推送。原因房间销毁或重置时没有移除这个房间实体上的消息订阅也没有清理Actor消息队列。上一局延迟的消息在新一局开始后继续投递到同一个RoomEntity导致状态污染。解决房间销毁前必须做清理调用RemoveComponent移除牌局组件取消订阅的事件清空MessageQueue。如果是重置而不是销毁要保证消息队列里的旧消息已经全部处理完再执行ResetDesk。我现在习惯在房间Service的OnDestroy里统一做清理而不是散落在各个Handler里。这个习惯比任何调试技巧都管用。6. 进阶用对拍脚本和压测验证Demo是否正确Demo能跑通只是第一步证明它总是正确是另一回事。牌局类项目最适合用“对拍”来验证写一批构造的牌型用例跑同一个判断函数输出有没有断言失败。这比手动点几十轮牌高效得多。6.1 牌型判断批量对拍我在Unity的Editor目录或者服务端测试工程里放一组测试用例[Test] public void Straight_With_Joker_Should_Be_Invalid() { // 顺子带王应该判为非法牌型 var cards new Listint { 0, 1, 2, 3, 52 }; Assert.AreEqual(CardType.None, CardLogic.GetCardType(cards)); } [Test] public void Bomb_Should_Beat_Straight() { var straight new Listint { 8, 9, 10, 11, 12 }; // 6 7 8 9 10 var bomb new Listint { 4, 8, 12, 16 }; // 四张3 Assert.IsTrue(CardLogic.CanBeat( CardLogic.GetCardType(straight), 10, CardLogic.GetCardType(bomb), 3)); }这类用例要覆盖单张、对子、三带一、三带二、顺子的边界5张成立、4张不成立、连对、飞机带单、炸弹压顺子、王炸压炸弹、2不能进顺子这八项。每项都算出预期结果再断言跑一遍比人工试一百轮更可靠。我现在接手任何牌局项目第一件事就是把牌型判断单独拎出来做对拍哪怕它已经跑了很久。6.2 压测和日志习惯网络层压测不需要复杂工具写一个简单的并发循环就行var tasks new ListETTask(); for (int i 0; i 30; i) { int userId i; tasks.Add(RunPlayer(userId)); } await ETTask.WaitAll(tasks);RunPlayer里模拟登录、创建房间、加入房间、循环出牌把每次请求的耗时打点统计。30路并发能让服务端消息处理瓶颈暴露出来比如某个Handler里做了耗时的同步操作压测日志会明显看到响应时间飙升。日志方面我习惯给每局牌加一个全局唯一的局号所有日志打印带上局号和座位号。排查跨局串号时按局号过滤日志一眼就能定位。6.3 一局结束后的复盘最近一次做类似项目我把牌型对拍脚本、30路压测和局号日志三样都上了。效果是排查消息串线从“靠猜”变成了“按局号拉日志”。如果你想把这个Demo往深了做下一步可以加断线重连、叫地主超时自动处理、机器人托管。这些都是很好的进阶练习核心牌局逻辑不用动。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网