简介:一份基于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,只是生命周期归属不同:实体通过AddComponent<T>()挂载子组件,父子关系由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.GetComponent<PlayerComponent>().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到 Gate,Gate 路由到 Room 实体的队列,Room 通过消息驱动整个流程。理解这条链路,后面改玩法、加机器人都会顺手很多。
3. 把 Demo 跑起来:服务端进程拓扑与首次登录的断线排查
3.1 环境前置:Unity 和 .NET 的版本对齐
我在这类项目上踩过最疼的坑就是环境错位。拿到 Demo 先锁版本,ET 6.0 用 .NET 6 SDK,Unity 建议用 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必须是全局唯一 ID,Actor 消息投递依靠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.GetComponent<NetClientComponent>().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(List<Card> 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 List<Card>() { 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 List<Card>() { 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”
原因:进程启动顺序不对。账号登录请求先到 Login,Login 需要访问 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.GetComponent<NetClientComponent>().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 的压测场景,确认消息链路没有断点后再开始改业务。一个能跑通全流程的样板,比任何文档都能更快让人摸清框架脾气。希望帮到你。
本文还有配套的精品资源,点击获取