简介:面向游戏开发者的斗地主Demo,基于开源ET框架搭建,重点服务于希望借助完整示例快速上手ET4.0的初学者。项目将分布式部署、高性能网络通信、自动内存管理、热更新与Actor事件驱动模型融入实际玩法,配合Server与Unity客户端代码,可以直观理解服务器逻辑、协议交互和界面呈现之间的关系。压缩包共10487个文件,大小约81.99MB,主要内容包括大量C#源码、Unity场景与预制体、动态库和可执行程序,以及Proto通信协议、本地启动脚本、使用说明、构建输出等,目录划分清晰,便于按模块查阅与边读代码边验证。已有1132人浏览学习。通过分析牌局流程、消息协议和热更新流程,能够积累联机游戏从服务器到客户端的完整开发经验,是ET框架入门与项目实践的高性价比参考资料,也适合作为网络游戏项目的二次开发起点。
1. 基于ET框架的斗地主Demo:不是玩具,是入门联机博弈的捷径
很多人拿到一个斗地主Demo,第一反应是:洗牌、发牌、比大小,能有什么好拆的。真动手把它跑成能三人联机的对局才发现,卡住你的从来不是牌型算法,而是ECS框架怎么组织数据、客户端和服务端的消息怎么不走丢、中途断线怎么把牌局恢复回来。这份基于ET框架的斗地主Demo,把这些最劝退的点一次性摆齐了:Entity/Component驱动、协程管理回合、服务器权威校验、断线重连快照。它适合两类人:一类是想学ET框架却找不到完整案例的Unity开发者,另一类是想要一个看得懂的状态机加联网流程的服务器开发。它不能让你学会AI斗地主,但能让你理解一局联机游戏从玩家点击到三个客户端同步,中间到底发生了什么。
2. 先跑起来再谈原理:ET框架的骨架、启动流程和Demo落点
2.1 先搞清楚ET替你管了什么
ET框架本质是一套用C#写成的分布式游戏服务器框架,最大的特点是ECS驱动:服务器进程内部存在大量Entity,每个Entity挂若干Component,行为通过System和EventSystem触发。初看你可能觉得这套东西是为了炫技,但放在斗地主这个场景里,它刚好把问题切得非常干净。一张牌桌就是一个RoomEntity,桌上三个玩家各是一块RoomPlayerSlot,底牌、手牌、回合、托管分别拆成组件,互不打扰。这么做最直接的好处是:玩家断线后挂一个AutoPlayComponent就能接管操作,而不需要改掉原本的牌类对象。
按ET的惯例,数据和逻辑是分开的,你很少看到一个大类里又存字段又写方法。我一般在拆这种Demo时,第一步就是画一张“Entity→Component”的图,先把数据归属看清楚。这一步看着简单,但能帮你省掉后面大量找字段的翻车时间。ET里Entity是生命周期的主体,Component是挂在Entity上的数据容器,System负责业务逻辑。房间切换状态、玩家进出座、回合推进,这些动作的源头都是消息或定时器,而不是某个组件直接调用另一个组件。
public class RoomEntity : Entity { public int RoomId { get; set; } public RoomState State { get; set; } } public class RoomPlayerSlot : Entity { public long PlayerId { get; set; } public int SeatIndex { get; set; } public HandCardsComponent HandCards { get; set; } public bool IsRobot { get; set; } }这段代码表达了两层意思:RoomEntity只关心这张桌子自己的状态(是等待中、叫分中还是出牌中),而每个座位单独成为实体,座位上的手牌再单独下沉到HandCardsComponent里。这样做的直接收益是,服务端广播桌面变化时,不需要把整个玩家对象序列化传出去,只需要传SeatIndex和变化相关的牌。ET框架里,Entity之间的父子关系也是同步的,你把RoomPlayerSlot挂在RoomEntity下,这个Demo里做房间迁移时,子实体可以跟着父实体一起走,省掉大量手工维护代码。
光有数据和实体还不够,组件之间的协作要靠事件。比如回合轮到某个座位时,TurnComponent会对外发布一个TurnStart事件,AutoPlayComponent订阅了这个事件,就能在下一帧决定要不要启动托管。这种“发布-订阅”在ET里是标准做法,EventSystem.Publish接受一个事件类型,所有对该类型感兴趣的组件都会在同一个异步流程里收到通知。你不需要维护一个长长的回调列表,新增一个监听方只是多注册一个类的事。这也是这个Demo适合作为ET入门案例的原因:一场牌局里事件类型本来就只有十种左右,用来理解事件驱动恰好够用。
2.2 从下载到跑通:启动顺序和三个参数坑
拿到源码后,建议先不读代码,而是先按依赖关系把环境跑起来。ET的服务器是多个独立进程组成,每个进程通过启动参数指定自己扮演什么角色。这套斗地主Demo通常需要三类角色:负责登录发凭证的Realm进程、负责维护客户端连接的Gate进程、负责承载牌局逻辑的Map或Room进程。常见启动方式是在编译后通过启动器或命令行分别拉起,我在本地调试时一般按这个顺序:
dotnet Server.dll --AppType=Realm --Console=1 dotnet Server.dll --AppType=Gate --Console=1 dotnet Server.dll --AppType=Map --Console=1三条命令对应三个网络节点。Realm是给客户端发登录凭证的,Gate是所有客户端的统一入口,Map才真正创建房间、推进回合。先把Realm拉起来,客户端才能拿到登录票据;拿到票据后连接Gate,再由Gate转交到Map。如果你跳过Realm直接连Gate,客户端会报登录态缺失;如果Map还没起来,客户端会卡在创建房间那一步,也就是常见的“房间服务不可用”。这个启动顺序是这套框架最常见的坑,你最好把它写进启动脚本,不要靠手速。
下面是本地调试时最常改的几个参数,我把它们放一起对比,方便你调的时候直接查:
| 配置项 | 常见位置 | 作用 | 最常见的坑 |
|---|---|---|---|
| ServerIP | 客户端启动命令行或配置 | 服务器地址,本机调试写127.0.0.1 | 填成真实内网IP,模拟器反而连不上 |
| Port | 服务端配置 | Gate对外端口 | 端口被占用,表现为连接后立即断开 |
| AppType | 服务端启动参数 | 决定进程身份 | 少起一个Map,界面一直在转圈 |
这三个里面最隐蔽的是ServerIP。你如果只在电脑上跑客户端,写127.0.0.1是安全的;一旦你放到另一台机器或模拟器上测试,就得把IP改成能被对方访问到的地址,同时确保防火墙放行了对应端口。不要在任何地方写死IP,我习惯把它们收敛到一个启动脚本里,方便每次切换场景。
跑起来之后怎么确认环境是好的?打开服务端控制台,正常情况下你会看到三条进程各自打出了监听地址。接着用客户端登录一个测试账号,成功后会看到Gate日志里出现一条新连接记录,说明客户端已经完成Realm登录并进了Gate。此时再创建房间,Map进程会打出“创建房间成功,房间号xxx”。如果这几步日志都能对上,说明基础链路已经通,可以进入代码阅读阶段了。
2.3 组件拆分:手牌、回合和托管长什么样
继续往下看代码前,先把Demo里出现频率最高的几个组件理一遍。HandCardsComponent只管“这个座位有哪些牌”和“按面值排序”,不关心现在轮到谁;TurnComponent保存当前操作座位、本轮剩余时间、上一手牌组合;AutoPlayComponent是机器人专用,它监听回合状态,一旦发现自己被叫到,就按简单策略出牌。这种拆法最大的好处是,出牌逻辑和桌面对局逻辑被解耦,你可以把AutoPlayComponent挂到任何玩家身上做托管,也能把它摘下来做纯手动。
public class HandCardsComponent : Entity { public List<CardData> Cards = new(); public void RemoveCards(List<CardData> removed) { foreach (var card in removed) Cards.Remove(card); Cards.Sort((a, b) => a.Face.CompareTo(b.Face)); } }我经常会看到有人把这个类写成含出牌方法的“Player”,然后里面同时有手牌、金币、头像、好友列表,最后代码几天就腐化。这个Demo给的示范是:一个组件只做一件事,手牌组件管增删,回合组件管时序,AI组件管决策,彼此之间通过事件通信。你看到这里可能会觉得文件变多,但实际排查问题时,定位一个bug往往只需要打开一个类。手牌组件里的RemoveCards有两步:先删除,再排序。顺序不能反过来,否则排序后的List索引和界面上的牌位会错位,这是我在调试时踩过的真实坑。删除时还有一处细节:如果同一张牌在手牌里出现两次,Cards.Remove只删第一个匹配项,所以出牌前必须先做牌型校验,确认你要删的组合确实存在,再调用RemoveCards。这套顺序逻辑在Demo的服务端是放在一个独立的PlayCardHandler里实现的,客户端只负责发牌组编号。
3. 斗地主的规则核心:牌型判定、回合状态机与出牌校验
3.1 牌型从A到小王的编码方式
任何斗地主服务端,第一步就是把牌桌语言变成程序语言。斗地主虽然用花色区分物理牌,但比大小时花色完全不参与,牌值大小决定一切。所以常见做法是给每张牌直接编码一个Face值,从3开始到王结束,中间的顺子、连对判断全靠这个Face。这个Demo里采用的是从3到17的编码方案,人为跳过了1和2这两个值。
public enum CardSuit { Diamond, Club, Heart, Spade, Joker } public enum CardFace { Three = 3, Four = 4, Five = 5, Six = 6, Seven = 7, Eight = 8, Nine = 9, Ten = 10, Jack = 11, Queen = 12, King = 13, Ace = 14, Two = 15, SmallJoker = 16, BigJoker = 17 } public struct CardData { public CardSuit Suit; public CardFace Face; }我把花色和Face放在一起解释,是为了强调它们分工不同。Face负责大小比较和牌型识别,Suit只负责UI显示和洗牌时的随机顺序。Face从3开始而不是从1开始,是为了让Face这个值能直接参与排序和大小比较,3是最小的普通牌,2是最大的普通牌,王再往上。你拿这个枚举去做一颗“手牌排序器”,按Face升序排列后显示在UI上,天然就是斗地主的标准手牌顺序,不用再额外映射。需要注意的一点是大小王没有花色,两张王也不能同时出现在某个普通的“对子”里,所以牌型识别必须把王单独拎出来。如果你要用别的编码,例如1到13,那么每次比较前都必须加一个大小映射表,多一层间接就多一层出错的可能。
3.2 出牌校验:怎么判断一手牌合不合法
服务端收到出牌请求后,第一件事是判断这手牌本身的牌型是否合法,第二件事是判断它能不能压过桌面上的上一手牌。牌型判定是所有逻辑里最容易写错的部分。我先给一个核心方法,它的职责是把一手牌归纳成“单张/对子/三带一”等类型。
public ComboType Analyze(List<CardData> cards) { if (cards.Count == 1) return ComboType.Single; var groups = cards .GroupBy(c => c.Face) .Select(g => (Face: g.Key, Count: g.Count())) .OrderByDescending(g => g.Count) .ThenByDescending(g => g.Face) .ToList(); if (groups.Count == 2 && groups[0].Count == 2 && groups[1].Count == 2 && groups.All(g => g.Face == (int)CardFace.SmallJoker || g.Face == (int)CardFace.BigJoker)) return ComboType.Rocket; // 省略顺子、飞机等分支,完整版见Demo源码 return ComboType.None; }这里关键的思路是先用GroupBy把相同Face的牌聚成一组,再按数量和Face排序。单张、对子、三条一眼就能看出来;炸弹是四张同Face;火箭的判断必须在前面,因为两个王如果走普通逻辑会被误判成一个“对子”,这在真实牌局里是飞不了的,代码也要单独拦截。这段代码看起来短,但它决定了后面所有比较逻辑的入口:只要Analyze返回None,服务端直接拒绝这次出牌,不需要再往下比大小。我调试时会在Analyze后面加一个日志,每手牌都打印识别结果,不然你很难知道到底是被哪一条规则拦下来的。
顺子这类牌型就麻烦一点,因为它的Face必须连续,而且数量有限制。常见的实现里,拿到分组以后先过滤掉大小为2和王的部分,只看普通牌的数量是否连续;连对和飞机则要求每个Face都恰好出现两次或三次。这里最容易翻车的点是“2不能进顺子”,很多初学者把2编成15号,却忘了在顺子判断里把15号排除,结果打出一手“10-J-Q-K-A-2”的非法牌型。Demo的服务端会专门走一个CheckStraight方法,先判断是否包含Two及以上Face,再判断连续性。我把这个经验写在这里:凡是跟牌型识别有关的函数,第一行就做边界值排除,不要等后续分支。
3.3 回合状态机:叫分、加倍、出牌的状态不允许跳步
牌型校验过了,接下来是回合管理。斗地主不是随时都能出牌的,一局牌要经过等待叫分、叫地主、加倍、出牌、结算五个阶段,每个阶段能做的动作完全不同。用状态机管理是最常规的做法,但这个Demo里有一个容易被忽略的点:状态机既要管理房间,又要管理每个座位自己的可操作状态。
public class TurnMachine { public RoomState State { get; private set; } public int CurrentSeat { get; private set; } public void EnterPlayPhase() { State = RoomState.Playing; CurrentSeat = FirstSeat(); Broadcast(new G2C_TurnStart { Seat = CurrentSeat }); } }我把它单独抽出来讲,是因为很多新手会直接在客户端写一个if-else去控制按钮可点不可点,结果服务端一个超时通知没到,两个玩家的客户端状态就不一致了。正确的做法是服务端永远是唯一裁决者,它广播进入出牌阶段后,客户端才把出牌按钮亮出来,超时或托管也是服务端说了算,客户端只负责显示。你在改这个Demo时如果发现“三个人界面状态不一样”,十有八九是在客户端本地画了状态机,这步要推翻重来。
一局牌的状态流转可以参考下面这张表,它同时也给出了每个状态下允许接收的消息,我在调这个Demo时就是照着这张表核对消息是否发错的:
| 当前状态 | 允许动作 | 下一个状态 |
|---|---|---|
| WaitBid | 叫分/不叫 | Bidding / 下一个玩家 |
| Bidding | 确认地主 | Doubling |
| Doubling | 加倍/不加倍 | Playing |
| Playing | 出牌/过/托管 | Playing(换座位)或 Settled |
| Settled | 继续下一局/离开 | 重新进入 WaitBid |
看到这里你应该能理解,为什么说“出牌状态机不允许跳步”:玩家不可能在WaitBid阶段直接打出两张牌,也不可能在Playing阶段再返回去叫分。服务端只需要检查当前收到的消息类型是否属于这张表里允许的集合,就能挡掉绝大部分非法请求。这个设计比在客户端依赖按钮置灰要可靠得多,因为任何客户端都可以被改,服务端校验才是最后一道门。
4. 联机同步:消息协议、RPC链路与断线重连
4.1 消息先定义好,后面少吵架
斗地主对实时性的要求远没有MOBA那么高,它一回合最长可以等你十来秒,所以消息通信用请求-响应式就够,不需要每帧推全量状态。你拿到代码后,第一件值得做的事是先看消息定义文件,而不是看出牌逻辑。这个Demo里最核心的三条消息是:客户端请求出牌、服务端应答出牌结果、服务端广播下一家开始。
public class C2G_PlayCard : MessageObject { public int Seq { get; set; } public int SeatIndex { get; set; } public List<int> Cards { get; set; } } public class G2C_PlayCardResult : MessageObject { public int Error { get; set; } public int NextSeatIndex { get; set; } public long TurnDeadline { get; set; } }这里我在Demo基础上补了一个字段:Seq客户端自增序号。它看起来不起眼,实际是防止玩家连点按钮时同一条出牌请求被重复发两遍。服务端按Seq去重,具体做法是在房间内维护一个lastSeq,如果收到的Seq不大于它就直接丢弃。为什么非要这样:斗地主一次出牌的影响很大,重复出牌可能导致同一手牌被连续减两回,手牌变负。很多Demo没有做这个防护,一局里遇到卡顿连点就崩。
4.2 出牌动作的完整链路
客户端点击出牌后,完整链路是这样的:客户端先把选中的牌在本地做一次合法性提示——注意这只是提示,不是裁决;然后消息发到Gate,Gate按房间归属关系把消息转给Map;Map里的房间服务拿到消息后,先检查当前操作席位是否是请求者,再走一遍3.2节的手牌合法性校验,接着从手牌里扣掉这些牌,最后把“谁出了什么牌”“下一家是谁”广播给室内三个客户端。
这套链路里最核心的纪律是:客户端本地校验只为了用户体验,服务端校验才是权威。你哪怕在客户端把牌型识别结果写错了,只要服务端校验严格,也不会污染对局。反过来如果服务端校验是错的,三个客户端再聪明也没用。这个Demo的服务端里,出牌处理器是一个纯函数式的校验器,输入是手牌快照和请求牌组,输出是合法或非法,不直接改房间状态,我比较推荐这种写法,因为方便在回放时重放验证。纯函数意思是同一个输入永远得到同一个输出,不依赖RoomId、不依赖当前时间,也不依赖牌桌之外的任何状态,这样两边都能单独写单测。
4.3 断线重连:重进游戏时怎么能回到刚才那局
联机牌局最麻烦的是玩家中途掉线。掉了以后重新进,客户端不能靠“猜”来恢复,服务端必须给出一份全量快照。快照里至少包含:三个座位各自的手牌数量(不是牌面,避免透视)、当前操作座位编号、桌面上最后一手牌、剩余倒计时、底牌三张,以及当前房间状态。手牌内容按Session单独下发,只发给本人。
public class G2C_ReconnectSnapshot : MessageObject { public List<SeatBrief> Seats { get; set; } public int CurrentSeat { get; set; } public List<int> LastPlayedCards { get; set; } public long LastPlayedSeat { get; set; } public long TurnDeadline { get; set; } }我见过不少重连是只补发增量数据,比如“小明出了对3,请把这两张牌从你本地去掉”。这种思路在简单对局里能跑,但一旦遇到玩家整个进程被杀、重开后本地状态已经清空的情况,增量补丁根本无从下手。所以这个Demo给的解法是重连即全量重建,客户端收到快照后把界面推倒重画。这个方法看起来很笨,代价也只是几张牌的序列化,换来的是状态永远不会依赖本地缓存。这里还有一个小坑:快照里的手牌数量应该只给其他两个座位看,自己的手牌列表单独发,避免把别的玩家手牌泄露给当前重连者。
4.4 消息乱序:上一次广播晚了,把新状态覆盖掉
多进程联机里还有一类玄学问题:客户端收到的广播不保证有序。Demo里出牌结果和托管超时通知可能走不同路径,有时候上一手牌的广播因为网络抖动晚到了几毫秒,先前的“旧状态”反而覆盖了“新状态”,界面显示反而倒退。常规做法是给每轮广播带上回合序号或单调递增的版本号,客户端维护一个本地版本号,凡版本号小于当前的直接丢弃。这套Demo的房间里,广播都带Turn版本号,你如果自己改造,千万别把这个字段删掉,它值几个通宵。除了版本号,服务端广播的顺序也要保证:先发扣牌结果,再发下一家开始,两个消息之间有因果关系,不能异步同时发给客户端。我在这个Demo的Map进程里专门维护了一个消息队列,同类广播严格按顺序出队,配合版本号,基本可以杜绝乱序导致的界面回退。
5. 避坑与排查:跑Demo时不翻车的五个血泪经验
5.1 进程启动顺序不对,登录按钮一直转圈
现象:客户端输入账号后,点登录一直没有响应,服务端控制台也看不到报错。
原因:Realm和Gate拉起来了,Map没有启动。客户端拿到登录票据后连接Gate,创建房间时找不到房间服务,整个请求被挂起。
解决:把三个进程写进一键启动脚本,按Realm、Gate、Map顺序拉起;Map进程里加一行“房间服务已就绪”的Log,脚本检测到日志出现后再放行客户端。我从那以后再也没有手工点过启动。如果你不确定当前机器上哪些端口在监听,在服务端跑一条netstat -ano | findstr 端口号,哪一步缺失一眼就能看出来。
5.2 双王被识别成对子
现象:玩家出了大小王,服务端提示非法,本地校验却显示合法。
原因:牌型识别里没有单独处理火箭,两个王先被GroupBy成两组,每组Count都是1,可以被普通逻辑当成“对子”放行,但真正的对子必须来自同一个Face的两张同值普通牌。
解决:在Analyze里把双王判断放在最前面,识别为Rocket,并把它跟炸弹放同一优先级。顺带加一条:双王不能作为顺子的一部分,这是规则要求,不是代码偷懒。改完后把“王炸”加入自动化测试用例,每次改牌型逻辑都跑一遍。你也可以单独写一个UnitTest,输入大小王,断言Analyze返回Rocket;再加一条普通对3,断言Pair,两个用例放在一起跑,就能彻底挡住这类回归。
5.3 倒计时结束没人出牌,房间卡死
现象:某玩家超时,倒计时结束后既没有自动出牌,也没有跳过,房间永远停在这一回合。
原因:倒计时只写在客户端UI上,服务端没有倒计时也没有托管逻辑。客户端卡顿或切后台,计时器停了,服务端等不到消息,整个房间变成僵尸桌。
解决:服务端在进入每个回合时启动协程倒计时,时间到后,若该座位是机器人或已经托管,则调用机器人策略自动出牌;若玩家仍在但无动作,强制标记为托管。给一个ET风格的伪代码:
async ETTask RunTurnTimer(TurnComponent turn) { long waitMs = turn.WaitMs; while (waitMs > 0) { await ETTask.Delay(1000); waitMs -= 1000; if (turn.Progressed) return; } AutoPlay(turn); }这段代码的核心逻辑是服务端自己数秒,绝不在客户端远程定时。加上这个机制后,不管玩家在不在,牌局都能往前走。参数上,turn.WaitMs我一般设置成15秒,倒计时打印在日志里,方便观察是哪一秒触发的托管。另外注意turn.Progressed的赋值时机:放在成功出牌或托管出牌之后,否则玩家正常出牌后依然会再次触发AutoPlay。
5.4 多线程日志乱成一团
现象:排查问题时,控制台输出里两条不同房间的日志混在一行,很难定位是谁先谁后。
原因:ET框架天然是多线程跑协程,房间切换时只打了消息内容,没带上进程名和房间号,日志文件里看不出上下文。
解决:统一打结构化前缀,例如“[Room:1024][Turn:7][Seat:2]”。我一般会在项目里封装一个LogContext工具,把当前房间号和回合版本号塞进一条记录里。这样按关键词过滤时,一条错误的完整上下文都能捞出来。格式化之后的日志大概长这样:Room:1024 | Turn:7 | Seat:2 | PlayCard Faces=[4,4,5] | Result=OK。另一种更准的做法是给每一条出牌校验日志都打上牌组摘要,比对回放和实时日志时一眼就能认出同一手牌。
5.5 重连后手牌比断线前少一张
现象:玩家断线重连后,手牌总数少了一张,还以为是服务端偷牌。
原因:重连时只给客户端发了当前操作人信息,没有发玩家自己的手牌列表,客户端沿用旧的本机缓存去渲染,而旧缓存里恰好在断线前丢了一次UI刷新。
解决:把所有重连逻辑改成4.3节的全量快照方案,手牌列表每次由服务端重新下发,绝不复用客户端缓存。这条经验后来被我写进所有牌局项目的重连规范里,宁可多传输几百字节,也不要因为省流量留下不可复现的状态bug。测试时用两分钟主动断网模拟极端情况,而不是只点取消按钮;断网重连后手牌数量必须和断线前一致,这一点写进验收标准。另外建议在重连快照里加一个handCount字段,客户端渲染完手牌后立刻比对数量,对不上直接报错而不是继续玩。
6. 把Demo改造成训练场:加机器人、回放与压测验证
6.1 在空座位上挂一个机器人
先别急着做AI,先用最简单的方式让它不卡住:给机器人挂AutoPlayComponent;服务端轮到这个座位时先查这个组件,有就调用出牌策略。策略可以是:有能压上的最小牌就出,没有就过。
public List<CardData> AutoDecision(TurnComponent turn) { var candidates = GetPlayableCards(turn.LastCombo); if (candidates.Count == 0) return null; return PickSmallest(candidates); }这段代码是最低限度的托管逻辑,验证状态机和消息链路已经够了。再加一条回放记录,你就能用大量自动对局来检查服务端是否出过非法牌型。
6.2 回放:把每局谁出了什么牌存下来
回放就是把每轮动作录成事件流。每一条记录都带回合号、座位、牌组和时间戳。对局结束后统一落盘。回放不是为了做观战,而是为了验证:用录下的牌组重新跑一遍服务端校验器,如果重放结果和当初结果不一致,说明逻辑有非确定性或状态残留。我一般会对回放做两层检查:第一层看非法牌型是否为0,第二层看总手牌进出是否守恒。
6.3 给压测写一个量化检查
跑1000局自动对局后,统计三类数据:非法牌型次数、平均每局时长、超时接管次数。这三项分别对应逻辑正确性、节奏合理性、托管健壮性。拿到这套Demo后建议先跑一遍这个统计,再谈优化。等数字稳定后,再去换更强的人机策略。
从那以后,我每次拿到新的联机Demo,都强制走一遍“跑通→找状态机→看消息→挂机器人→录回放”的流程,中间踩过的坑就用第5章的清单兜底。这次按这个顺序走下来,最值的不是看懂了斗地主,而是把ET框架从“听说过”变成了“自己修过bug”的程度。希望帮到你。
本文还有配套的精品资源,点击获取