简介一份基于ET框架开发的斗地主Demo专为想快速上手ET4.0的游戏开发者准备。该项目将ET框架的分布式部署、高性能网络、自动内存管理、热更新及Actor事件驱动等核心特性融入完整示例通过分析服务器与客户端代码可清晰理解框架结构、网络通信实现和Unity界面设计方式。压缩包共10487个文件约81.99MB以cs源码、dll库、meta资源文件为主同时包含协议定义的proto文件、启动bat脚本、txt使用说明以及Unity工程所需的prefab、unity3d、asset等资源目录涵盖Server、Unity、Proto、Build等模块便于按需查阅。已有1132人学习下载。借助该Demo可实际动手编译运行掌握热更新流程与协议定义将ET框架的理论知识转化为可操作的开发经验适合刚接触ET框架或希望借鉴完整游戏示例的开发者。1. 基于ET框架的斗地主Demo一个能跑的分布式游戏后端长什么样做网络对战游戏最头疼的不是渲染是同步。斗地主这种房间制游戏牌型算法、状态机、广播顺序、断线重连看着简单真要做上线每个环节都能让新手磨掉半个月。这份“基于ET框架的斗地主Demo”解决的就是这个问题它把ET框架的实体组件系统、Actor消息通信、RPC调用和完整玩法串成了一条可运行链路。前端Unity后端全是.NET跑起来就是能登录、能建房、能发牌、能出牌的小游戏。它不是一个空壳脚手架而是带业务逻辑的完整样板适合第一次接触ET框架的客户端开发、想转服务端的Unity程序员以及想找参考实现的独立开发者。2. 再看一遍 Demo 架构ECS 和消息驱动才是主菜2.1 实体-组件系统为什么一个实体不是 GameObject而是纯 C# 对象ET 框架 6.0 之后重构了实体-组件系统。它不是 Unity 那个挂在 GameObject 上的 MonoBehaviour而是自己维护的一套纯 C# 实体树。实体类继承Entity组件类也继承Entity只是生命周期归属不同实体通过AddComponentT()挂载子组件父子关系由Parent指针管理销毁时一级级释放。这套设计让逻辑不依赖场景里的 GameObject服务器和客户端可以用同一套代码。在 Demo 里你会发现 Player 不是一个物理对象而是一个逻辑实体身上挂着PlayerComponent、NumericComponent这类数据组件。比如玩家的金币数量、出牌状态都存进组件里系统逻辑则在Hotfix目录下的静态类中调用。这样做的核心诉求是热重载和复用。更换服务器代码时把逻辑 dll 单独加载数据类不动游戏服务就不会因为字段序列化错位而全部崩掉。注意ET 里的组件更像数据容器不是常规的“组件模式”里的行为对象。想要给某个实体加功能优先考虑挂载组件然后在对应的系统类里写处理函数。2.2 消息三兄弟IMessage、IRequest/IResponse 与 RPC 的调用链消息是 ET 框架网络通信的基础。大致分三类普通消息IMessage、请求IRequest、响应IResponse。客户端向服务器发登录请求定义C2G_LoginGate继承IRequest服务端处理后返回G2C_LoginGate继承IResponse。这个结构靠协议生成器自动生成通常源头是一份 Excel 或 proto 定义。在客户端调用时网络实体上挂一个Session直接用session.Call()发起远程调用// 客户端请求登录网关key 是登录流程中获取的临时凭证 var loginGate (G2C_LoginGate)await session.Call(new C2G_LoginGate() { Key key }); if (loginGate.Error ErrorCode.ERR_Success) { // 登录成功更新当前玩家对应的 GateSession Game.Scene.GetComponentPlayerComponent().GateSession session; } else { Log.Error($登录网关失败: {loginGate.Error}); }Call是请求-响应模型底层自动做了消息路由和超时处理。返回值里带的Error字段是框架统一错误码非 0 就表示业务失败这是排查问题时的第一道信号。一个常见的误用是把Send当成Call用Send发完就忘记等结果。斗地 main 里的登录、创建房间、出牌校验都依赖响应结果比如出牌后必须知道服务器是否接受这手牌才能决定是否切换轮次。所以业务上凡是需要回执的全部走Call只有广播类动作比如其他玩家加入了房间、手牌刷新列表才用Send。2.3 从 Demo 里找代码Actor 消息如何驱动出牌逻辑ET 参考 Orleans 实现了 Actor 模型。简单理解每个绑定了 Actor 的实体都有一个独立的消息处理队列发往该实体的消息都会投递到队列里按顺序执行。这样不需要锁因为操作一个实体状态的代码始终是单线程的。斗地主 Demo 里房间实体就是一个 Actor它收到的消息包括玩家准备、开始游戏、出牌请求。我一般在代码里找这种入口函数// Room 是房间实体每个玩家出牌都会通过路由投递到这里 public async ETTask OnPlayerPlayCard(Player player, PlayCardRequest request) { // 校验当前是否轮到该玩家 if (player.Index ! this.currentPlayerIndex) { // 不是他的回合直接拒绝 return; } // 校验手牌合法性 if (!CardRule.Validate(request.Cards, this.lastPlayedCards)) { // 牌型不合法或管不上拒绝 return; } // 广播该玩家出牌 this.RoomMessage(new RoomPlayingUpdate() { PlayerIndex player.Index, Cards request.Cards }); this.lastPlayedCards request.Cards; this.currentPlayerIndex (this.currentPlayerIndex 1) % this.playerCount; }注意这段逻辑里没有加锁因为OnPlayerPlayCard在 Actor 的队列里按序执行。如果改成给room挂一把lock反而可能造成后续异步调用时死锁。新手第一次接触这个模型最容易在 Entity 内部开多线程自己锁这是错误方向。把状态变更全部收敛到 Actor 的入口方法里就好。从这里能看到整个 Demo 的骨架客户端发PlayCardRequest到 GateGate 路由到 Room 实体的队列Room 通过消息驱动整个流程。理解这条链路后面改玩法、加机器人都会顺手很多。3. 把 Demo 跑起来服务端进程拓扑与首次登录的断线排查3.1 环境前置Unity 和 .NET 的版本对齐我在这类项目上踩过最疼的坑就是环境错位。拿到 Demo 先锁版本ET 6.0 用 .NET 6 SDKUnity 建议用 2021.3 LTS 或更高编辑器版本太低会导致导入的System.*dll 冲突。下载好源码后先打开服务端解决方案编译# 检查 dotnet 版本必须 ≥ 6.0 dotnet --list-sdks # 还原并编译解决方案 dotnet build ./Server.sln -c Release编译出现错误时先看错误输出里是不是缺少 NuGet 包。ET 依赖的包在Server.sln里通过 nuget.config 指定源如果网络受限导致还原失败就要手动修改源地址。这一步走通了后面进程启动才会顺利。3.2 启动服务端进程拆分的思路和启动方式ET 的服务端不是单进程而是把功能拆成了多个 App比如 Login 负责账号验证、Gate 负责客户端连接转发、Room/Map 负责房间玩法逻辑DB 负责存档。在 Demo 的服务器目录里通常有批处理Windows 下一行行启动# 启动数据库进程负责账号和存档持久化 dotnet App.dll --AppType DB --Process 1 # 启动登录进程 dotnet App.dll --AppType Login --Process 2 # 启动网关进程 dotnet App.dll --AppType Gate --Process 3 # 启动房间进程 dotnet App.dll --AppType Room --Process 4这里有个参数细节--Process必须是全局唯一 IDActor 消息投递依靠AppType Process定位目标服务器。如果你把 Gate 和 Room 都配成--Process 3启动时大概率不会立刻报错但玩家一进房间就再无响应消息全丢。3.3 客户端联调第一次登录失败看哪里客户端工程用 Unity 打开后首先要找到启动场景并确认连接地址配置。在 Demo 里客户端启动时会读取一份配置来定位 Gate 的地址和端口。// 这段通常在启动场景的入口脚本里 var gateConfig new GateConfig { Address 127.0.0.1, Port 10002, AppType Gate }; Game.Scene.GetComponentNetClientComponent().CreateSession(gateConfig);首次连接失败时查看客户端的日志窗口关键词是connect fail。如果看到连接超时优先查服务端进程是不是还活着。ET 的日志默认输出在启动目录的Logs文件夹下报错里有明确的异常堆栈。提示连不上的时候先跑一遍dotnet build再确认所有进程都拉起。我见过一半原因不是配置写错而是 Room 进程启动后因为端口被占用又退了回去。4. 玩法核心房间状态机与一手牌怎么验证4.1 状态机设计从准备到结算的一次流转斗地主的房间不能只是一个简陋的布尔开关必须拆成状态机。Demo 里通常会有类似这样的枚举public enum RoomPhase { Wait 0, // 等待玩家进入 Deal 1, // 发牌阶段 Playing 2, // 出牌阶段 Settle 3 // 结算阶段 }状态迁移规则我这样梳理房间内人数达到 3 且全部点击准备进入 Deal发牌完成后自动进入 Playing某玩家出完牌或所有人都不出牌时结果确定进入 Settle结算数据写库后回到 Wait。写代码时最容易犯的错误是每个客户端单独维护这份状态。正确姿势是状态机只存在服务器客户端通过广播同步拿到最新状态。Demo 里是用RoomPhaseChanged这类消息把状态广播给房间内所有玩家客户端收到后刷新 UI 按钮的可用性。4.2 牌型识别一手牌是顺子还是飞机代码怎么判断牌型识别是斗地主 Demo 里最核心的业务代码。先看一手牌能否通过同点数分组public static CardType CheckCardType(ListCard cards) { // 按点数分组比如三张 5 会聚合到同一个 key 下 var groups cards.GroupBy(c c.Rank) .OrderBy(g g.Key) .Select(g g.ToList()) .ToList(); int count groups.Count; // 单张 if (cards.Count 1) return CardType.Single; // 对子 if (cards.Count 2 groups[0].Count 2) return CardType.Pair; // 三带一、三带二 if (cards.Count 4 || cards.Count 5) { var hasTriplet groups.Any(g g.Count 3); if (hasTriplet cards.Count 4) return CardType.TripletWithSingle; if (hasTriplet cards.Count 5) return CardType.TripletWithPair; } // 顺子至少 5 张点数为递增连续且不能出现 2 和王 if (cards.Count 5 groups.All(g g.Count 1)) { bool isStraight true; for (int i 1; i count; i) { if (groups[i].Key - groups[i - 1].Key ! 1) isStraight false; } if (isStraight groups.All(g g.Key ! Rank.R2 g.Key ! Rank.Joker)) return CardType.Straight; } // 炸弹和火箭 if (groups.Count 1) { if (groups[0].Count 4) return CardType.Bomb; } else if (cards.Count 2 groups.All(g g.Key Rank.SmallJoker || g.Key Rank.BigJoker)) { return CardType.Rocket; } return CardType.Invalid; }逻辑说明这个方法把相同点数的牌分组根据组数和每组数量识别牌型。顺序很关键先判定单张、对子再判断三带最后判断顺子避免顺子把三带一误判成无效。参数上最需要注意的是Rank的枚举顺序2和大小王不参与顺子它们的排名值也不相邻。4.3 一手牌能不能大过上家比较规则不是简单比大小斗地主比较一手牌的大小需要满足“手牌张数相同牌型相同最大点数大于上家”但炸弹和火箭有特权。Demo 里比较逻辑通常这样写上家牌型当前牌型能否大过单张 10单张 J能对子 3对子 5能顺子 3-7顺子 4-8能任意非炸弹炸弹能炸弹更大炸弹能任意牌型火箭能火箭火箭/炸弹不能比较函数里注意一点炸弹和火箭之间没有绝对大小关系火箭是最大的牌这就是为什么识别函数里必须把火箭放在最后。注意很多新手在实现时会拿“最大单牌”比较这会漏掉顺子要求“同长度”的边界。必须比较整手牌型而不是只看最大点数。4.4 用单元测试把牌型校验钉死这个 Demo 的代码仓库里如果带了测试工程一定要跑一遍没带的话我习惯自己补上一份测试数据。手动出牌找人试效率太低直接写个循环把牌型枚举组合起来验证[Test] public void Test_Bomb_CanBeat_Straight() { var straight new ListCard() { new Card(Rank.R3, Suit.Club), new Card(Rank.R4, Suit.Club), new Card(Rank.R5, Suit.Club), new Card(Rank.R6, Suit.Club), new Card(Rank.R7, Suit.Club) }; var bomb new ListCard() { new Card(Rank.R9, Suit.Club), new Card(Rank.R9, Suit.Diamond), new Card(Rank.R9, Suit.Heart), new Card(Rank.R9, Suit.Spade) }; Assert.IsTrue(CardRule.CanPlay(bomb, straight)); Assert.IsFalse(CardRule.CanPlay(straight, bomb)); }这类测试的价值在于牌型合法性一旦改错测试失败会精确指到具体规则而不是等上线后玩家发现牌打不出去。跑一遍测试的成本比在游戏里点半小时高得多。5. 常见问题与避坑记录五次翻车背后的真实原因5.1 现象客户端卡在登录界面服务端日志只有 “connect fail”原因进程启动顺序不对。账号登录请求先到 LoginLogin 需要访问 DB 进程加载账号数据。如果 DB 没启动Login 内部等待超时网关也不会给客户端回包。解决严格按照 DB → Login → Gate → Room 的顺序启动。我一般把启动进程的脚本录成一条 bat 或 shell 命令保证顺序不会出错。5.2 现象发牌后手牌出现重复两张一模一样的方块 8原因发牌算法从牌堆 List 里取牌时没有用索引移除而是用值移除但如果碰巧有一对同点数同花色的鬼牌没有区分就会出现删错。更隐蔽的是洗牌用了Random没固定种子每次发牌顺序不同但可能重复。解决洗牌用 Fisher-Yates 算法牌堆每张唯一。发牌时用List.RemoveAt(index)取出即删。// Fisher-Yates 洗牌标准写法 for (int i deck.Count - 1; i 0; i--) { int j random.Next(i 1); (deck[i], deck[j]) (deck[j], deck[i]); }5.3 现象玩家出完牌后下家一直无法操作轮次卡死原因出牌后房间实体没有把currentPlayerIndex做越界保护三人都出过牌后状态没有回到起始者广播时丢失了NextTurn。解决在服务端每轮结束时强制校验索引有效性并检查状态机是否还停留在 Playing。如果状态异常就用错误码拒绝请求并触发一次全房间状态同步。5.4 现象断线重连后玩家看不到自己的手牌原因断线重连时 Session 变了但服务器内存里玩家实体的 Session 引用没有更新导致家具房间推送给玩家的消息还是发到旧通道。解决重连成功后在网关层通过账号 ID 找到原来的玩家实体把Player.Session重新赋值为新 Session同时让房间再广播一次完整手牌和当前出牌状态。5.5 现象发布到 Linux 服务器后服务端起不来报 “unable to load shared library”原因ET 用的某些原生库是 Windows 版或者编译时没选对 RuntimeIdentifier。解决发布时用平台对应参数例如 Linux x64 要加--runtime linux-x64。不指定时默认可能带上当前编译机的运行环境换机器就会翻车。6. 进阶把 Demo 变成你的压力测试台与业务脚手架6.1 用 Robot 客户端压房间提前暴露并发问题Demo 里如果带了 Robot 组件可以用它模拟多玩家自动进房自动出牌。没有的话也可以写一个简单的并发脚本生成多个客户端 Session同时向 Gate 发请求。常见压力场景是“一局打完开下一局”这时候房间列表清理、玩家复用都会触发内存异常。// 模拟 10 个客户端同时登录网关 for (int i 0; i 10; i) { var session Game.Scene.GetComponentNetClientComponent().CreateSession(gateConfig); var response (G2C_LoginGate)await session.Call(new C2G_LoginGate()); sessions.Add(session); }压力测试结果里重点看两个指标请求超时率和服务端日志里的异常堆栈。如果超时集中在 Room 进程说明房间数量太多或某个房间的逻辑阻塞了 Actor 队列。6.2 服务端热重载不是所有代码都好热更ET 框架支持服务端逻辑热重载但踩过一次就明白了静态变量、单例缓存不会随新的 dll 自动清理。比如房间状态机里如果把随机数种子缓存在静态字段热重载后旧种子会继续影响后续发牌。我的习惯是热重载前先把当前运行的房间全部安全退出让玩家重新登录。这样的代价比热更过程中发现数据错乱要小太多。6.3 把斗地主改成跑得快换牌型规则要改哪几个文件拿这个 Demo 去改玩法时先理清边界。跑得快和斗地主的区别在于牌型里没有三带一、三带二顺子必须为 5 张飞机也取消。改动集中在CardRule类里的CheckCardType和CanPlay以及叫地主逻辑。房间状态机不需要动广播机制也不需要动。如果你能在这个 Demo 上把牌型规则换掉并测试通过说明你对这套框架的消息链路已经真正掌握了。从那以后我每次接手一个网络对战项目都会先跑一遍 Demo 的压测场景确认消息链路没有断点后再开始改业务。一个能跑通全流程的样板比任何文档都能更快让人摸清框架脾气。希望帮到你。本文还有配套的精品资源点击获取
