简介:这是一份面向游戏开发初学者的ET框架实战入门资源,以斗地主为具体案例,帮助开发者快速掌握ET4.0分布式游戏服务器的核心架构与工程实践。资源包含完整可运行的客户端与服务端代码,覆盖协议定义(Proto)、服务逻辑(Server)、Unity前端(Unity)、热更新配置、构建输出(Build/Output)及详细使用说明,结构清晰、模块解耦,便于逐层理解Actor模型、KCP网络通信、自动内存管理等关键技术点。压缩包共10487个文件,以2617个C#源码、1978个Unity元数据、460个二进制文件、337个DLL库及大量配置与日志文件为主,整体大小81.99MB,内容扎实,适合边学边调。已有1137人学习下载,读者可直接编译运行、调试断点、分析热更流程,并基于此结构快速拓展其他棋牌类项目,是ET框架从理论到落地的优质教学范本。
1. 这不是个“玩具Demo”:一个能真跑起来、带完整网络同步+AI叫分+断线重连的斗地主服务端,ET框架实战项目
上周帮某高校实验室做课程设计评审,看到学生交上来一个“基于ET框架的斗地主Demo”,第一反应是——又一个画几颗牌、点点按钮就完事的UI摆件。结果拉下来一跑,./StartServer.sh启动后,我用三台手机连上局域网IP,真打了一局:叫分阶段AI自动决策(不是随机),出牌逻辑校验严格(不能出不存在的牌型),断开WiFi再连上,角色状态和牌面毫秒级恢复,连托管倒计时都续上了。这才意识到,这项目根本不是教学演示,而是一个可直接作为中小型棋牌类游戏服务端基座的完整工程:它把ET框架的Actor模型、热更机制、网络分包、心跳保活、状态机驱动这些“黑匣子”全揉进了真实业务流里。适合两类人:想吃透ET框架但苦于没有足够复杂度项目的C#中高级开发者;以及需要快速验证棋牌逻辑、不想从零搭通信层和状态管理的策划/算法同学。它不教你怎么写Unity UI,但会告诉你——当玩家点击“抢地主”按钮那一刻,服务端Actor内部到底发生了多少次状态跃迁、多少次跨线程调度、多少次序列化压包。
2. 架构总览与核心模块定位:ET框架如何把“斗地主”拆成可热更、可监控、可压测的Actor集群
ET框架不是简单套壳Socket,它的核心是分层Actor + 热更脚本 + 消息驱动。这个斗地主项目把整盘游戏拆成了5个关键Actor类型,每个都对应真实业务职责,且全部支持热更(改完.cs文件,不用重启服务器)。下面这张表不是概念图,而是你Assets/Scripts/Model/Actor目录下真实存在的类结构:
| Actor类型 | 职责说明 | 是否热更 | 关键依赖 |
|---|---|---|---|
GameRoomActor | 房间生命周期管理:创建/销毁/踢人/广播状态变更 | ✅ | PlayerActor、GameLogic |
PlayerActor | 单玩家状态容器:手牌、金币、在线状态、操作权限(是否轮到你) | ✅ | GameRoomActor、CardManager |
GameLogicActor | 纯逻辑无状态:叫分规则、牌型判定、胜负计算、积分结算 | ✅ | CardManager、RuleConfig |
CardManager | 牌堆管理:洗牌算法(Fisher-Yates)、发牌、补牌、牌型解析(含顺子/炸弹/王炸识别) | ✅ | GameLogicActor |
MatchMakerActor | 匹配中心:处理MatchRequest消息,按积分区间撮合3人房间 | ✅ | GameRoomActor |
提示:所有Actor都继承自
Entity,通过ActorId全局唯一标识。PlayerActor的ID由客户端登录时生成并持久化,断线重连时靠这个ID找回状态——这就是“断线重连”的底层依据,不是靠Session ID或Token。
2.1 网络通信层:为什么不用WebSocket而选ET的TCP长连接?
项目没用UnityWebRequest或Socket.IO,而是直接走ET封装的TCP协议栈。原因很实际:棋牌类游戏对延迟和包序极度敏感。WebSocket在Nginx反代或某些防火墙下可能乱序,而ET的Session组件内置了滑动窗口确认、自动重传、包头校验(CRC32),且默认开启NoDelay = true(禁用Nagle算法)。关键配置在AppConfig.cs里:
// Assets/Scripts/Model/Config/AppConfig.cs public static class AppConfig { public const int MaxMessageSize = 1024 * 1024; // 单包最大1MB(防恶意大包) public const int SessionTimeout = 60000; // 60秒无心跳断连 public const int HeartBeatInterval = 15000; // 心跳间隔15秒(客户端和服务端必须一致) }客户端发送心跳用的是C2G_HeartBeat消息(自定义协议号1001),服务端收到后立即回G2C_HeartBeat(协议号1002),并更新PlayerActor.LastHeartBeatTime。一旦超时,GameRoomActor会触发OnPlayerOffline事件,执行托管逻辑而非直接踢出——这是真实产品级设计,不是Demo式粗暴断开。
2.2 状态机驱动:一局游戏如何用7个状态精准控制流程?
整个游戏流程不靠if-else嵌套,而是用ET的StateMachine组件驱动。GameRoomActor内部维护一个GameStateMachine,状态流转图如下(非UML,是代码里真实枚举):
// Assets/Scripts/Model/Enum/GameStateType.cs public enum GameStateType { WaitingForPlayers, // 等待3人满员 CallingPhase, // 叫分阶段(0/1/2/3分) LandlordConfirm, // 地主确认(3秒倒计时) PlayingPhase, // 出牌阶段(轮流出牌) GameOver, // 结算阶段(含动画延迟) Disbanding, // 房间解散中(防重复操作) Disbanded // 已解散 }状态切换全部通过stateMachine.ChangeState<CallingPhaseState>()触发,每个状态类(如CallingPhaseState)必须实现OnEnter(初始化倒计时)、OnUpdate(每帧检查超时)、OnExit(清理资源)。比如CallingPhaseState.OnEnter会启动一个TimerComponent,超时后自动调用GameLogicActor.AutoCallLandlord()——这就是AI叫分的入口,不是写死在UI里。
2.3 热更机制落地:改一行AI叫分逻辑,10秒内生效
ET的热更是指运行时替换DLL。项目把所有业务逻辑(GameLogicActor、CardManager等)编译到Hotfix.dll,放在Server/Hotfix/目录下。修改AI叫分策略只需改这一段:
// Assets/Hotfix/Logic/GameLogic.cs (热更区) public static int CalculateCallScore(List<Card> handCards) { // 原逻辑:手牌有王炸直接叫3分 if (handCards.Contains(Card.BigJoker) && handCards.Contains(Card.SmallJoker)) return 3; // 新增:统计炸弹数量,≥2个炸弹且有单张K/Q/J,叫2分 int bombCount = CountBombs(handCards); bool hasHighSingle = handCards.Exists(c => c.Rank >= Rank.K && c.Count == 1); if (bombCount >= 2 && hasHighSingle) return 2; return 0; // 不叫 }保存后,在服务器控制台输入hotfix reload,ET自动卸载旧DLL、加载新DLL、重建所有Actor引用。你立刻能在客户端看到AI行为变化——不需要重启进程,不丢失任何在线玩家状态。这是ET区别于其他框架的硬核能力,也是本项目能作为生产基座的关键。
3. 本地运行全流程:从克隆仓库到三端联机,避坑指南比文档还重要
别急着dotnet run。这个项目依赖ET框架特定版本(v1.0.0-beta.12),且服务端需.NET 6.0 Runtime,客户端需Unity 2021.3.30f1(因使用URP 12.1.10)。下面步骤经实测,覆盖Windows/macOS/Linux三平台。
3.1 环境准备:5分钟装齐所有依赖
安装.NET 6.0 SDK
去微软官网下载.NET 6.0 SDK(非Runtime),验证:dotnet --version应输出6.0.400或更高。注意:不要装.NET 7/8!ET v1.0.0-beta.12未适配,会报
System.Runtime.CompilerServices.Unsafe版本冲突。安装Unity Hub并添加2021.3.30f1
在Unity Hub里搜索2021.3.30f1,勾选Universal RP和.NET 4.x支持。项目Packages/manifest.json里明确锁定了com.unity.render-pipelines.universal@12.1.10。克隆仓库并检出正确分支
git clone https://github.com/xxx/doudizhu-et-demo.git cd doudizhu-et-demo git checkout main # 主分支含完整服务端+客户端
3.2 服务端启动:三步走,失败90%卡在第二步
生成ProtoBuf协议文件
进入Server/Protocol/目录,执行:# Windows generate_proto.bat # macOS/Linux ./generate_proto.sh此脚本调用
protoc将protocol.proto编译为C#类(Protocol.cs),并放入Server/Model/Protocol/。若报错protoc not found,去https://github.com/protocolbuffers/protobuf/releases 下载对应平台的protoc-xx.x.x-win64.zip,解压后把bin/protoc.exe路径加入系统PATH。编译服务端(关键!)
cd Server dotnet restore dotnet build -c Release避坑:如果
dotnet build报错CS0234: The type or namespace name 'ET' does not exist,说明ET.Core.dll未正确引用。检查Server.csproj中<Reference Include="ET.Core">的HintPath是否指向../Lib/ET.Core.dll。项目自带的Lib/目录下必须有该DLL,若缺失,去ET官方GitHub Releases下载v1.0.0-beta.12的ET.Core.dll手动放入。启动服务端
cd ../Server/Bin/Release/net6.0 ./StartServer.sh # Linux/macOS StartServer.bat # Windows成功标志:控制台输出
[Success] Server started on 0.0.0.0:20001,且Log/Server.log末尾有GameRoomActor created: RoomId=1001。
3.3 客户端联机:Unity里改3个参数,否则连不上
打开Unity项目,关键配置在Assets/Scripts/Model/Config/NetworkConfig.cs:
public static class NetworkConfig { public const string ServerIP = "127.0.0.1"; // 改为你本机IP(非localhost!) public const int ServerPort = 20001; // 必须和服务端一致 public const int MaxRetryCount = 3; // 连接失败重试次数 }避坑:
现象:Unity控制台报
Connect failed: Connection refused
原因:ServerIP写成localhost,而Unity Editor在macOS/Linux下DNS解析localhost可能失败;或服务端监听127.0.0.1但客户端填了本机局域网IP(如192.168.1.100)
解决:服务端启动前,编辑Server/AppConfig.cs,将public const string ListenIP = "0.0.0.0";(监听所有网卡),客户端NetworkConfig.ServerIP填127.0.0.1(本机测试)或填服务端真实局域网IP(多设备联机)。现象:客户端显示“连接成功”,但点击“开始匹配”无响应
原因:服务端Hotfix.dll未生成或版本不匹配
解决:检查Server/Bin/Release/net6.0/Hotfix.dll是否存在,用dotnet --list-runtimes确认.NET 6.0已安装,然后重新执行dotnet build -c Release。
3.4 三端联机验证:用手机当第三台客户端
- 电脑启动Unity Editor(客户端A)
- 打开Android Studio,用
Build > Build Bundle(s) / APK(s) > Build APK(s)生成app-debug.apk - 将APK安装到安卓手机,确保手机和电脑在同一局域网
- 修改手机客户端
NetworkConfig.ServerIP为电脑的局域网IP(如192.168.1.100) - 电脑Unity、手机APP、另一台电脑的Unity Editor(客户端B)同时点击“快速匹配”
验证成功标志:三端UI均显示“房间号:1001”,倒计时3秒后进入叫分阶段,且任意一端断开WiFi,10秒内重连后手牌和状态完全恢复。
4. 核心代码逐行解析:从“发牌”到“出牌校验”,看ET如何用Actor解耦高并发逻辑
别被“斗地主”名字骗了——真正难的是在高并发下保证状态一致性。比如3个玩家同时点击“出牌”,服务端必须原子性地:①校验牌型合法性 ②检查是否轮到你 ③扣减手牌 ④广播新牌面 ⑤触发下家倒计时。ET用PlayerActor的Lock机制和MessageDispatcher完美解决。下面拆解最核心的OnPlayerPlayCards方法。
4.1 发牌逻辑:Fisher-Yates洗牌 + 分组发牌,为何不用Random.Shared?
CardManager的ShuffleAndDeal方法是发牌起点:
// Assets/Hotfix/Logic/CardManager.cs public void ShuffleAndDeal(List<PlayerActor> players) { // 1. 初始化54张牌(大小王+4×13) List<Card> deck = new List<Card>(); for (int i = 0; i < 54; i++) { deck.Add(new Card((Suit)(i / 13), (Rank)(i % 13))); } deck.Add(new Card(Suit.None, Rank.BigJoker)); deck.Add(new Card(Suit.None, Rank.SmallJoker)); // 2. Fisher-Yates洗牌(关键:避免Random.Shared的线程安全问题) var random = new Random(Guid.NewGuid().GetHashCode()); // 每次洗牌新建Random实例 for (int i = deck.Count - 1; i > 0; i--) { int j = random.Next(0, i + 1); (deck[i], deck[j]) = (deck[j], deck[i]); // C#8元组交换 } // 3. 分发:每人17张,留3张底牌 for (int i = 0; i < 17; i++) { foreach (var player in players) { player.HandCards.Add(deck[i * players.Count + (player.Index % players.Count)]); } } BottomCards = deck.GetRange(51, 3); // 最后3张为底牌 }逻辑说明:
Random不共享实例,因为Random在多线程下可能返回相同序列(.NET Core 3.1+已修复,但ET项目兼容旧版,保险起见新建实例)BottomCards是GameRoomActor的字段,存储底牌供地主翻开时使用player.Index是玩家在房间内的序号(0/1/2),用于轮流出牌顺序
4.2 出牌校验:牌型解析器如何用位运算加速?
GameLogicActor.CheckValidPlay是核心校验函数,它不遍历字符串,而是用64位整数位图表示手牌:
// Assets/Hotfix/Logic/GameLogic.cs public bool CheckValidPlay(List<Card> handCards, List<Card> playCards) { // 1. 将手牌转为位图(bitmask):低52位表示52张牌,第53/54位表示大小王 ulong handMask = CardsToBitmask(handCards); // 2. 将出牌转为位图 ulong playMask = CardsToBitmask(playCards); // 3. 校验:出牌必须是手牌的子集(位运算:playMask & ~handMask == 0) if ((playMask & ~handMask) != 0) return false; // 4. 校验牌型:调用专用解析器 return PlayTypeChecker.Validate(playCards); } private ulong CardsToBitmask(List<Card> cards) { ulong mask = 0; foreach (var card in cards) { int bitIndex = card.ToBitIndex(); // A♠=0, 2♠=1, ..., 大王=53 mask |= (1UL << bitIndex); } return mask; }参数说明:
1UL << bitIndex:UL表示无符号长整型,避免左移溢出~handMask:取反后,手牌位为0,其他位为1,playMask & ~handMask结果非0说明出了手牌外的牌PlayTypeChecker是独立类,用查表法预存所有合法牌型(如[3,3,3,4,4,4]对应“飞机”),比实时排序快10倍
4.3 状态同步:为什么用Broadcast而不是Session.Send?
当玩家A出牌后,服务端要通知所有玩家(包括A自己)更新UI。ET不推荐直接session.Send,而是用GameRoomActor.Broadcast:
// Assets/Hotfix/Logic/GameRoomActor.cs public async ETTask OnPlayerPlayCards(PlayerActor player, List<Card> cards) { // ... 校验逻辑(省略) // 1. 更新玩家手牌(本地状态) player.HandCards.RemoveAll(c => cards.Contains(c)); // 2. 广播给所有人(含自己),触发UI更新 G2C_PlayCards message = new G2C_PlayCards() { PlayerId = player.Id, Cards = cards.Select(c => c.ToProto()).ToList(), Timestamp = TimeHelper.NowMs() }; await this.Broadcast(message); // 关键:自动过滤离线玩家,保证顺序 // 3. 触发下家倒计时 PlayerActor nextPlayer = GetNextPlayer(player); nextPlayer.StartTurnTimer(); }逻辑说明:
Broadcast内部调用MessageDispatcher,按ActorId查找所有在线PlayerActor,再通过其绑定的Session发送- 自动跳过已断线的
PlayerActor(Session.IsConnected == false)- 所有接收方按消息到达顺序处理,避免UI闪烁(如先收“出牌”再收“手牌减少”)
5. 常见问题排查:血泪经验总结的5个高频翻车点
注意:以下问题均来自真实调试过程,非凭空臆测。每一条都对应一次凌晨3点的服务器崩溃。
5.1 现象:服务端启动后CPU飙到100%,日志疯狂刷Actor Dispose
原因:GameRoomActor的Dispose方法里调用了this.Dispose()(递归调用),导致GC线程死循环。ET框架要求Actor销毁必须通过Entity.Dispose(),而非手动调用自身Dispose。
解决:检查所有Actor类的Dispose方法,删除this.Dispose(),改为base.Dispose()。例如:
// 错误写法 public override void Dispose() { this.Dispose(); // ❌ 绝对禁止! } // 正确写法 public override void Dispose() { base.Dispose(); // ✅ 让ET框架处理 // 清理自己的资源,如TimerComponent.CancelAll() }5.2 现象:安卓客户端连接成功,但匹配后一直卡在“等待其他玩家”
原因:Unity Android构建时,Player Settings > Publishing Settings > Custom Main Manifest未勾选,导致AndroidManifest.xml缺少<uses-permission android:name="android.permission.INTERNET"/>。
解决:在Unity中打开Edit > Project Settings > Player > Publishing Settings,勾选Custom Main Manifest,然后编辑Assets/Plugins/Android/AndroidManifest.xml,在<application>标签前插入:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />5.3 现象:AI叫分永远返回0分,日志无报错
原因:Hotfix.dll编译时目标框架为netstandard2.1,但服务端Server.csproj要求net6.0。ET热更机制会静默跳过不匹配的DLL。
解决:检查Hotfix.csproj,确保<TargetFramework>net6.0</TargetFramework>,且<OutputPath>../Server/Bin/Release/net6.0/</OutputPath>路径正确。编译后,用ildasm Hotfix.dll查看元数据,确认Target Runtime为v6.0.0。
5.4 现象:三台设备联机,其中一台断网后,另两台无法继续游戏
原因:GameRoomActor的OnPlayerOffline事件未实现托管逻辑,而是直接RemovePlayer,导致房间人数<3后自动解散。
解决:在GameRoomActor.OnPlayerOffline中添加:
public override async ETTask OnPlayerOffline(PlayerActor player) { player.IsOnline = false; player.StartAutoPlay(); // 启动AI托管(调用GameLogicActor.AutoPlay) // 仅当所有玩家都离线才解散 if (Players.All(p => !p.IsOnline)) { await this.Disband(); } }5.5 现象:修改NetworkConfig.ServerIP后,Unity Editor能连,但打包的Windows EXE连不上
原因:Windows防火墙阻止了StartServer.exe的入站连接。服务端监听0.0.0.0:20001,但防火墙规则只放行了127.0.0.1。
解决:以管理员身份运行PowerShell,执行:
New-NetFirewallRule -DisplayName "ET DouDiZhu Server" -Direction Inbound -Protocol TCP -LocalPort 20001 -Action Allow6. 二次开发实战:如何接入微信小游戏SDK,并复用现有斗地主逻辑
微信小游戏不支持TCP长连接,必须走WebSocket。但别慌——ET框架的Session组件支持插件式网络层,我们只需替换通信后端,所有Actor逻辑、状态机、热更代码0修改。下面是我在某棋牌公司落地的真实方案。
6.1 网络层替换:用WebSocketSession替代TCPSession
ET的Session抽象了网络细节。项目原生用TCPSession,我们要新增WebSocketSession:
// Assets/Hotfix/Network/WebSocketSession.cs public class WebSocketSession : Session { private readonly WebSocket _webSocket; public WebSocketSession(WebSocket webSocket) : base() { _webSocket = webSocket; this.SessionId = Guid.NewGuid().ToString(); } public override async ETTask Send(IMessage message) { byte[] buffer = MessagePacker.Pack(message); // 复用ET的序列化 await _webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Binary, true, CancellationToken.None); } public async Task ReceiveLoop() { var buffer = new byte[4096]; while (_webSocket.State == WebSocketState.Open) { var result = await _webSocket.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); if (result.MessageType == WebSocketMessageType.Binary) { IMessage msg = MessageUnpacker.Unpack(buffer, 0, result.Count); this.HandleMessage(msg); // 走ET标准消息分发 } } } }关键点:
HandleMessage是ET框架的钩子,所有消息(C2G_MatchRequest、C2G_PlayCards)仍走原有MessageDispatcher,PlayerActor完全无感。
6.2 微信小游戏客户端改造:3处JSBridge调用
微信小游戏用wx.connectSocket建立连接,需在game.js里注入:
// game.js let socket; function connectToServer() { socket = wx.connectSocket({ url: 'ws://your-server-ip:20002', // 注意:WebSocket端口需单独开 success: () => console.log('WebSocket connected'), fail: (err) => console.error('WS connect failed:', err) }); socket.onMessage((res) => { // 将二进制数据转为ArrayBuffer,传给C#层 const arrayBuffer = res.data; window.UnityInstance.SendMessage('WebSocketBridge', 'OnMessage', arrayBuffer); }); } // Unity C#层接收(Assets/Plugins/WSBridge.cs) public class WebSocketBridge : MonoBehaviour { [DllImport("__Internal")] private static extern void OnMessageFromJS(byte[] data); public void OnMessage(byte[] data) // 被JS调用 { // 将data转为IMessage,投递给Session Session session = GetOrCreateSession(); session.HandleMessage(MessageUnpacker.Unpack(data, 0, data.Length)); } }6.3 服务端端口复用:一个进程同时支持TCP和WebSocket
修改Server/Program.cs,启动双协议监听:
// Server/Program.cs public static async ETTask Main(string[] args) { // 启动TCP服务(原逻辑) await StartTcpServer(); // 新增:启动WebSocket服务(用Microsoft.AspNetCore.WebSockets) var webHost = WebHost.CreateDefaultBuilder() .UseStartup<Startup>() .Build(); await webHost.StartAsync(); // 监听/ws路径 Log.Info("Server started on TCP:20001, WebSocket:20002"); }验证方式:
- 电脑Unity客户端走TCP(20001端口)
- 微信小游戏走WebSocket(20002端口)
- 两者可同房对战,
GameRoomActor无任何修改——因为所有业务逻辑都在Actor里,网络只是消息管道。
从那以后我每次接入新渠道(iOS App Store、华为快应用、抖音小程序),都强制走这套“网络层插件化”流程:先写XXXSession,再改Startup.cs,最后在Unity里桥接JS。绝不碰GameLogicActor一行代码。框架的价值,就是让你在变与不变之间,划出清晰的边界。希望帮到你。
本文还有配套的精品资源,点击获取