
简介基于ET框架的斗地主Demo是一份面向游戏开发入门者的ET框架实践项目。ET框架本身支持分布式部署、高性能网络通信、热更新与Actor事件驱动模型该Demo通过完整的斗地主玩法将上述特性落实到可运行的工程中。压缩包共10487个文件以C#脚本、Unity资源与meta文件为主C#脚本承载服务端与客户端逻辑DLL与二进制文件为编译产物prefab与asset为Unity界面资源同时涵盖协议定义、配置文档等包体约81.99MB目录清晰划分为Server、Proto、Unity、Build等模块。目前已有1132人浏览学习通过学习这份资料开发者可以掌握ET框架下服务端与客户端的代码组织方式、网络通信协议的设计与解析、Unity客户端的界面与交互搭建并熟悉热更新发布流程适合希望将ET框架理论快速转化为实际开发能力的读者。1. 基于ET框架的斗地主Demo它到底在演示什么很多人在ET框架的Demo里翻车是从看懂了HelloWorld、却不知道怎么把它变成「一个真正能让玩家玩起来的游戏」开始的。ET框架是一个以C#为核心、天然面向分布式 Actor 模型的开源游戏服务器框架它的优秀之处不在单机表现而在「服务器逻辑即客户端逻辑」的共享代码范式。但真正让从业者卡住的恰恰是如何把实体组件事件ECS、Actor消息、状态同步这些抽象概念落到一张牌桌的完整流程里。斗地主Demo的价值就在这里牌桌天然有房间、玩家、回合、状态流转它逼迫你把框架的每个关键机制都用起来而不是停留在聊天室级别。这篇笔记我会从拆工程、写服务器逻辑、接客户端表现到压测验证完整讲一遍我自己复现和扩展这个Demo的路径适合已经跑通ET框架官方教程、开始想做具体玩法的客户端或服务端开发。2. 拆解ET框架的Demo工程三层代码与一条消息的完整生命周期2.1 Model层和Hotfix层为什么必须分开拿到ET框架的Demo源码千万别急着双击.sln启动Unity。先花半小时把目录结构读明白否则后面每个编译报错都像玄学。常见的ET框架Demo工程目录是这样的以实用为准不绑定某个特定版本Unity/Assets/ ├── Model/ # 纯数据结构和协议定义不引用Unity ├── Hotfix/ # 游戏逻辑可热更新的核心层 ├── ModelView/ # 视图层数据引用Unity但尽量薄 ├── HotfixView/ # 视图层逻辑UI、动画、输入可热更 └── ThirdParty/ # 第三方库和框架源码Model层里放的应该是纯C#定义房间状态枚举、出牌数据结构、玩家信息类。Hotfix层放的是游戏规则——叫地主逻辑、出牌合法性、胜负判定。ModelView和HotfixView则负责把数据变成屏幕上玩家看得见的东西。为什么要这么分因为ET框架的热更新方案只热更Hotfix和HotfixViewModel层一旦被Unity引用就得跟着主程序打包。如果你把斗地主的发牌逻辑写进Model以后改一个出牌规则就要整个客户端重发版热更新就名存实亡了。我一般会遵循一个简单原则所有规则类代码进Hotfix所有状态数据类代码进Model。用「出牌」举例玩家手牌列表是数据放Model判断这手牌能不能管住上家是规则放Hotfix。2.2 从服务器启动到客户端连入一次会话的建立过程ET框架的服务器是多进程的每个进程跑一个端口。斗地主Demo最常见的部署是三个进程登录服Realm、网关服Gate、游戏服Map。一个玩家从点开客户端到进入牌桌走的是这样一条链路客户端 - Realm验证账号 - 获取Gate地址 - 连接到Gate Gate - 创建或分配Map会话 - 进入房间场景理解这条链路非常重要因为斗地主的房间状态机、出牌广播和断线重连全都挂在Map服上。Realm和Gate只是入口真正干活的是Map服里的Room组件。下面我给出一个简化的服务器启动装配代码展示如何把必要组件装配到一个进程上// 服务器进程入口以Map服为例 public static class Entry { public static void Start() { // 1. 初始化日志、计时器和配置表 Game.Scene.AddComponentLogComponent(); Game.Scene.AddComponentTimerComponent(); // 2. 装配网络层监听指定端口 Game.Scene.AddComponentNetKcpComponent(); Game.Scene.AddComponentGateSessionKeyComponent(); // 管理网关会话 // 3. 注册Demo所需的服务器组件 Game.Scene.AddComponentRoomManagerComponent(); // 房间管理 Game.Scene.AddComponentNumericWatcherComponent(); // 数值监听 // 4. 启动异步消息循环 while (true) { Game.Scene.Update(); Thread.Sleep(1); // 每帧保留1ms给操作系统 } } }这段代码说明了ET框架服务器的启动方式组件驱动一切。NetKcpComponent负责网络收发RoomManagerComponent负责创建和销毁斗地主房间。新增功能就新增组件这是ECS思想的直观体现。实际运行中Thread.Sleep(1)是让出CPU时间片避免空轮询占满单核在低并发调试机上很有用。参数上的一个关键点是NetKcpComponent监听的端口要和客户端配置的服务器地址一致。很多新手把框架自带的地址配好能连上自己写了个房间服务就直接挂在同端口上结果服务器起不来——每个组件实例只允许一个端口这是框架的保护机制。2.3 消息协议的定义与Actor消息的发送路径斗地主Demo里消息协议的定义集中在Model层。你打开协议文件看到的是一堆带Message后缀的类。ET框架用protobuf做序列化但定义方式是直接在C#类上打特性标签// 协议定义客户端请求叫地主 [Message(OuterOpcode.C2S_CallLandlord)] public partial class C2S_CallLandlord : IMessage { public int Multiplier { get; set; } // 叫地主倍数或是否叫地主 } // 协议定义服务器广播叫地主结果 [Message(OuterOpcode.S2C_CallLandlordResult)] public partial class S2C_CallLandlordResult : IMessage { public long PlayerId { get; set; } public int Multiplier { get; set; } public int CurrentTurn { get; set; } }给类标记Message特性并指定opcode编号之后就能在代码里用new C2S_CallLandlord()构造消息并通过Session.Send()发送。发送路径上客户端发到Gate的是OuterMessageGate再包装成InnerMessage转发给Map服。ET框架有个很方便的机制你在客户端写C2S_CallLandlord在服务器用MessageHandlerC2S_CallLandlord就能接收到。中间经过Gate的转发逻辑是框架自动完成的。这说明了一件事ET框架的通信模型天然适合斗地主这种「多个客户端同时操作一个房间」的场景。因为每条客户端消息都会携带Session信息而Room组件管理者所有玩家Session。3. 服务器端斗地主核心逻辑房间状态机与出牌校验3.1 房间的状态流转从等待到结算斗地主最核心的是状态机。一个房间要经历WaitPlayer-DealCards-CallLandlord-PlayCard-Settle- 销毁或重开。我见过不少人把这个状态机写成一堆switch套if到后期根本改不动。ET框架的组件模型可以把每个状态做成一个组件但为了Demo可读性用状态枚举加一个状态管理组件更合适。下面是一个简化版Room状态管理组件的实现public class RoomComponent : Entity { public RoomState State { get; private set; } public Listlong PlayerIds new Listlong(3); public int CurrentTurnIndex; // 当前轮到哪个玩家出牌 public async ETTask SetState(RoomState newState) { // 离开旧状态 switch (State) { case RoomState.PlayCard: // 离开出牌阶段清理本回合临时数据 this.RemoveComponentPlayCardComponent(); break; } State newState; // 进入新状态 switch (State) { case RoomState.CallLandlord: this.AddComponentCallLandlordComponent(); break; case RoomState.PlayCard: this.AddComponentPlayCardComponent(); break; case RoomState.Settle: this.AddComponentSettleComponent(); break; } // 通知所有玩家状态变更 await ETTask.CompletedTask; } }这个实现的关键在于状态切换不是简单改个枚举而是通过AddComponent和RemoveComponent来挂载和卸载不同玩法组件。这么做的好处是斗地主的叫地主逻辑和出牌逻辑完全隔离互不污染字段。坏处是组件多了之后状态链路难追溯调试时要打日志。Demo阶段用它是很舒服的。ETTask是ET框架的异步返回类型配合协程使用。在服务器端你不需要像客户端那样在意主线程冻结但依然用await是因为组件生命周期在异步回调中可能已经变化await能保证后续操作发生在正确的同步上下文里。3.2 发牌与洗牌随机算法和筹码分配发牌逻辑是Demo里最不会错但最容易写难看的一块。ET框架Demo通常用RandomHelper工具类。注意服务器端全局的随机数一定要保证种子的统一性否则三端各自洗牌结果不一致。// 服务器发牌逻辑 public static ListCard[] DealCards() { // 初始化一副54张牌 ListCard allCards new ListCard(); for (int suit 0; suit 4; suit) { for (int rank 3; rank 15; rank) // 3~10, J/Q/K/A/2 { allCards.Add(new Card(suit, rank)); } } allCards.Add(new Card(4, 16)); // 小王 allCards.Add(new Card(4, 17)); // 大王 // 洗牌 RandomHelper.FisherYatesShuffle(allCards); // 发牌前51张发给三个玩家最后3张为底牌 ListCard[] hands new ListCard[3]; for (int i 0; i 3; i) { hands[i] allCards.GetRange(i * 17, 17); } ListCard bottom allCards.GetRange(51, 3); // 把底牌广播给所有玩家 return hands; }洗牌功能是ET框架的通用工具Fisher-Yates算法的核心是保证每个排列等概率注意不要在循环里用Random.Next()之外的别名算法去碰概率问题。参数说明牌值是3到15代表3到216是小王、17是大王这个编号方案贯穿整个Demo出牌比较大小全靠它不要中途改变映射规则。实际做的时候还要在发牌之后广播一次手牌给各玩家并且把底牌先保密——只广播数量而不广播内容。这个需求很常见做法是给玩家手牌消息和底牌消息分开发送我是把底牌消息放在叫地主结束之后推的。3.3 出牌合法性校验梯形规则用一张枚举表解决斗地主的出牌校验是所有初学者最头疼的。炸弹、顺子、连对、飞机、三带一、四带二……如果一个个写if校验函数会长到没法维护。我推荐用「牌型枚举 排序组」的方式把一次出牌视作一个组合先归类后比较。public enum CardPattern { Invalid 0, Single 1, // 单张 Pair 2, // 对子 Triple 3, // 三张 TripleWithOne 4, // 三带一 Straight 5, // 顺子 ... } public static CardPattern CheckPattern(ListCard cards) { if (cards.Count 0) return CardPattern.Invalid; var sorted cards.OrderBy(c c.Rank).ToList(); int count sorted.Count; // 统计点数出现次数 var groups sorted.GroupBy(c c.Rank) .Select(g new { Rank g.Key, Count g.Count() }) .OrderByDescending(g g.Count) .ToList(); // 初步分支判断 if (count 1) return CardPattern.Single; if (count 2 groups[0].Count 2) return CardPattern.Pair; if (count 3 groups[0].Count 3) return CardPattern.Triple; // 顺子最大点与最小点差等于长度减1且不存在对子 if (count 5 groups.All(g g.Count 1)) { int min sorted[0].Rank; int max sorted[count - 1].Rank; if (max - min count - 1 max 15) // 顺子中A不参与2和王也不参与 { return CardPattern.Straight; } } // 其他类型先略实际项目里按需求持续扩展 return CardPattern.Invalid; }这段代码的核心逻辑先把所有可能的牌型收敛成「分组数量 最大重复数 最大值」三个特征然后对特征组合做模式匹配。以后要加新牌型只在这里加一个分支即可不会影响其他规则。出牌校验中比较大小的规则很清晰同类型下比较关键牌的最大点数炸弹大于一切非炸弹火箭即双王大于炸弹。这是一个纯函数逻辑不依赖任何外部状态因此建议写成静态方法并放入Hotfix层方便未来做服务器压测和单元测试。3.4 用Actor模型实现回合流转谁在出牌何时广播ET框架的Actor模型是理解整个服务器端斗地主的钥匙。简单说每个玩家Session在Map服对应一个Player实体组件每个Room是一个Actor。玩家消息发给Room后Room在自身线程上下文执行逻辑并广播。这样能保证同一个房间的消息是串行处理的不需要加锁。// 处理玩家出牌消息 public class C2S_PlayCardHandler : AMActorHandlerRoomComponent, C2S_PlayCard { protected override async ETTask Run(RoomComponent room, C2S_PlayCard message) { // 1. 验证当前是否轮到该玩家 if (room.CurrentTurnIndex ! message.PlayerIndex) { // 不是他的回合直接返回并提示 return; } // 2. 从玩家手牌中移除打出的牌 var player room.GetChildPlayerComponent(message.PlayerId); bool removed player.RemoveCards(message.PlayedCards); if (!removed) { // 手牌中没有这些牌非法操作 return; } // 3. 校验牌型并确认是否压过上一手 var pattern CardHelper.CheckPattern(message.PlayedCards); if (pattern CardPattern.Invalid || !CardHelper.CompareGreater(message.PlayedCards, room.LastPlayedCards)) { // 牌型不合法把牌还回去并通知客户端出牌失败 player.AddCardsBack(message.PlayedCards); await room.SendToPlayer(message.PlayerId, new S2C_PlayCardFailed()); return; } // 4. 更新房间状态并广播给所有玩家 room.LastPlayedCards message.PlayedCards; room.CurrentTurnIndex (room.CurrentTurnIndex 1) % 3; await room.Broadcast(new S2C_PlayCardSuccess { PlayerId message.PlayerId, Cards message.PlayedCards, NextTurn room.CurrentTurnIndex }); } }这段Actor处理器展示了斗地主服务器的核心节奏每一步都是「校验——变更——广播」。AMActorHandler是ET框架提供的泛型基类它自动帮我们定位到消息对应的Room组件。最关键的是Run方法里的内存操作天然线程安全——因为同一个Room的消息只会由同一个线程逻辑链处理这是Actor模型最大的收益。注意AMActorHandler的第二个泛型参数是消息类型框架会依据消息里携带的ActorId来路由到正确的实体。在客户端构造消息时需要把Room的InstanceId带上否则服务器不知道该把消息送给谁。4. 客户端表现层协程驱动UI与状态同步4.1 从服务器消息到UI刷新用协程编排异步流程ET框架的客户端是Unity环境消息接收通常在ModelView层的会话组件里完成。根据消息类型分发到UI控制器。斗地主的UI逻辑不算复杂但最容易写乱的是「动画播放顺序」和「按钮点击时机」。用协程可以用同步的写法编排异步流程避免回调地狱。下面是我常用的客户端处理出牌成功消息的写法// 客户端UI控制器处理服务器回应 public class RoomPanel : Entity { private Dictionaryint, HandCardGroup handCards; public async ETTask OnPlayCardSuccess(S2C_PlayCardSuccess msg) { // 1. 刷新本地手牌从手牌组移除已出的牌 var myGroup handCards[msg.PlayerId myPlayerId ? 0 : msg.PlayerId]; myGroup.RemoveCards(msg.Cards); // 2. 播放动画先让牌移到桌面中央 await Game.Scene.GetComponentUIComponent() .GetDeskView().PlayCardAnimation(msg.PlayerId, msg.Cards); // 3. 更新回合指示器显示下一个出牌人的高亮 UpdateTurnIndicator(msg.NextTurn); // 4. 如果轮到自己启用出牌按钮 if (msg.NextTurn myPlayerId) { EnablePlayButton(); } else { DisablePlayButton(); } } }协程在这里最核心的价值是await PlayCardAnimation能让UI动画完整播完后才刷新按钮状态避免了「动画没播完玩家就狂点导致交互错乱」的经典问题。4.2 手牌排序与交互选中不涉及服务器状态的表现逻辑手牌组是斗地主UI中最需要打磨细节的部分。新手写手牌经常做成一个固定横向布局然后发现选中牌之后整排牌的位置漂移。正确的做法是手牌用HorizontalLayoutGroup加ContentSizeFitter在选中状态变化时改变牌的锚点偏移并调用RebuildLayout()。public class HandCardItem : MonoBehaviour { private Button button; private RectTransform rect; private bool isSelected; private float selectedOffset 30f; // 选中时上移像素 private void Start() { button GetComponentButton(); button.onClick.AddListener(OnClick); } private void OnClick() { isSelected !isSelected; float targetY isSelected ? selectedOffset : 0f; // 使用DOTween做弹性动画比直接改anchoredPosition顺滑 rect.DOAnchorPosY(targetY, 0.1f); } }注意selectedOffset是用像素做单位的在高DPI下需要按实际根节点缩放比例换算。另外连续点击时DOAnchorPosY会打断上一个动画所以DOTween的DOAnchorPosY一般配合SetId和OnComplete来防止动画队列堆积。斗地主的选牌逻辑还有一个常见需求双击自动出牌。我实现的方案是记录两次点击时间戳间隔小于0.3秒则把所有已选牌发送出去。你还需要提前保证手牌排序结果和服务器端校验逻辑一致否则客户端展示的牌型比服务器判定的弱体验会很奇怪。4.3 同步帧与预测斗地主这种回合制游戏要预测吗很多人做Demo会惯性思维认为联网游戏需要做客户端预测。斗地主是回合制游戏天然不需要帧同步和预测。客户端只在操作时发给服务器服务器广播后就地刷新客户端的「表现同步」完全依赖于服务器消息。但这不意味着网络抖动体验就一定好。服务器到客户端的消息有延迟玩家看到的手牌刷新可能有0.1到0.5秒延迟。解决方法是给玩家的出牌预留一个「确认间隔」客户端在发出去牌消息后立刻本地移除手牌并播放动画如果服务器返回失败再做回滚。这种做法在Demo里很简单但能显著提升手感。// 客户端在发送出牌请求后先本地移除并播放动画 await session.Send(new C2S_PlayCard { PlayerId myPlayerId, PlayedCards selectedCards }); // 本地先行移除 myGroup.RemoveCards(selectedCards); // 等服务器确认若失败则回滚 var result await session.Call(new C2S_ConfirmPlayCard() { PlayedCards selectedCards }) as S2C_PlayCardResult; if (!result.Success) { myGroup.AddCardsBack(selectedCards); }注意这里用Call而不是SendCall会等待服务器响应。这个模式不能滥用——如果每个操作都等服务器返回就会从「乐观执行」退化成「同步等待」。通常只对「失败惩罚明显」的操作使用比如出牌校验、叫地主结果。对于倒计时、发牌这类信息一律用Send加主动刷新。4.4 热重载调试改代码不要重编客户端ET框架最有吸引力的一个特性就是热重载。在Unity里开着播放模式改Hotfix代码改完按快捷键即可生效不需要重新编译整个客户端。具体做法是Unity菜单栏ET框架的热重载工具将Hotfix和HotfixView两个程序集的改动编译后通过网络发送到运行中的客户端。前提是你的架构遵守了之前说的依赖规则——Hotfix层中没有引用Unity API。我个人的建议是即使是Demo开发也务必从第一天就配置好热重载。因为斗地主的UI和规则迭代非常频繁每改一次手牌排序就重新编译一次一天下来进度损失有一两个小时。配置热重载的过程半小时内做完后面都是净收益。5. 拖垮Demo的五个经典坑现象、原因与解决5.1 手牌数据在客户端被篡改服务器直接吞消息现象玩家通过客户端调试工具伪造出牌消息把手牌里根本没有的牌发给服务器但服务器处理逻辑完全没反应玩家就像卡死。原因服务器端有校验。但校验失败时没有给客户端任何反馈客户端还停留在等待状态看起来就是游戏无响应。解决在校验失败的分支统一返回S2C_PlayCardFailed消息客户端收到后播放一个短暂抖动动画并禁用出牌按钮0.5秒。这不算什么高级设计但用户感知从「一直卡死」变成「我做了一次非法操作」。5.2 同一个Room的多个消息并发处理把状态搞乱了现象两个玩家同时出牌服务器端偶尔出现「牌被出了两次」或「回合计数错乱」。原因Actor模型保证了每个Room的消息按序处理但你如果绕过Actor通道直接在Room组件外使用另一个Session直接调用Room的方法就会破坏串行保证。解决所有针对房间的写操作都必须走AMActorHandler派发的消息处理不能在Gate层或登录流程里直接修改Room内部数据。排查方法是在RoomComponent的每个公开方法入口打日志记录调用栈看哪些操作不是从Actor消息处理器进来的。5.3 客户端倒计时和服务器时间不同步现象玩家看到倒计时还剩5秒服务器已经在3秒后判定他超时导致被踢出房间。原因客户端用本地DateTime.Now做倒计时服务器用服务器端时间轴。两个机器的时钟有偏差而超时判定发生在服务器。解决服务器在每次广播轮到你出牌时附带一个服务器时间戳和超时时长。客户端倒计时基于(服务器时间戳 超时时长 - 客户端当前时间)计算而不是直接做本地计时。更稳妥的方案是定期做时间同步校准在登录握手时下发服务器与客户端的差值。5.4 少数时候UI按钮点了没反应轮到你出牌却出不了现象玩家手牌都正常出牌按钮也能点点击后无响应随后被服务器超时踢掉。原因客户端使用Send发送出牌消息服务器校验失败后不会处理也不会通知客户端。典型是玩家选择了一张牌型不合法的手牌组合服务器只丢弃不反馈。解决出牌消息使用Call模式并设定超时时间。如果服务器超过1秒未返回客户端本地弹提示消息。同时在客户端本地做一次基础牌型检查能把大部分非法明显输入拦截在本地。5.5 新加协议后客户端和服务器的Opcode对不上现象新加一个「显示玩家头像」的消息后客户端收到的消息全部乱码老功能也崩溃了。原因ET框架的Opcode是序列化后按消息类名的Hash生成或手工指定的。当你手动指定Opcode时客户端和服务器必须使用完全一致的数字映射。很多时候两边代码不是同步改的少改了一边。解决设置一个独立的Opcode常量类客户端和服务器共享同一份源文件。每次新增消息在这个类里递增编号。同时在启动时做一个自检遍历所有标记了Message特性的类检查是否有重复Opcode有则直接抛异常并中断启动。这样问题几乎不可能带到运行阶段。6. 从Demo到可上线压测一百个房间时的三个验证技巧Demo跑通不是终点能稳定扛住一百个房间才是。斗地主这类游戏服务器压力主要来自消息吞吐和房间状态保存这里我分享三个我常用的验证技巧。第一个技巧是「无头客户端压测」。不要把Unity客户端作为压测工具因为Unity的开销太大一百个客户端就把本机内存吃光了。用C#写一个控制台项目引用ET框架的通信库模拟登录、进入房间、叫地主、出牌全流程。每开一个连接就模拟一个玩家每秒上报状态。我一般目标是本机单核能跑三百个模拟客户端无压力。第二个技巧是「断线重连必测」。斗地主里玩家中途掉线是常态服务器侧的Player实体要保留一段时间。我做的策略是掉线后保留Room里的Player 60秒期间该玩家不出牌由服务器托管自动过牌。测试时故意断开网络连接重连后查看手牌底牌是否完整、当前回合是否还在同一局、能否继续出牌。这个流程在Demo阶段至少跑三遍。第三个技巧是「服务器单帧耗时打点」。ET框架有Profiler工具但Demo阶段我习惯在房间状态机的入口和出口分别记录TimeHelper.ClientNow()差值。如果某局游戏单帧耗时超过10毫秒就说明房间内有人在执行密集的排序或校验逻辑。我遇到过一个问题玩家的手牌排序放在每次出牌后全量重新排序导致牌多时卡顿改成手牌增删局部插入排序后单帧耗时从14毫秒降到了1毫秒以内。最后说一个我自己的教训斗地主Demo不需要一开始就追求帧同步、断线重连、跨服匹配这些大而全的功能。先把一桌三个人从进房到结算的完整链路跑通把异常日志和超时提示做全再谈扩展。很多东西看起来简单真要压测一把几十局才能体会到服务器代码的质量差距。这个Demo方向是我认为ET框架入门里性价比很高的一个选择因为它的复杂度刚好够你把Actor、消息分发、协程、热重载全部用上又没有苛刻的物理和渲染要求。希望帮到你。本文还有配套的精品资源点击获取