从去年年初决定把三国杀这套规则完整复刻一遍,到现在这个开源项目已经连续维护了105天。这是这个系列的记录篇,“之二”是继第一篇整体思路之后的续写,后面的“105@365”是我给自己定的365天每日更新计划里的第105天。如果你正打算做一个规则复杂度比较高的卡牌游戏,或者想看看一个持续迭代的开源项目在100天之后会变成什么样,这篇应该能给你一点参考。
这个项目不是简单做个“能玩的单机三国杀”,而是从零实现了一套可扩展的规则引擎,支持多人在线联机、技能动态注册、回合状态机管理、卡牌判定流程等等。说白了,这是在拿三国杀的规则当磨刀石,练架构设计。这篇文章会把当前版本的核心模块拆开来讲,包括选型逻辑、牌堆与洗牌、回合状态机、技能触发机制、联机通信和踩过的坑。
1. 项目定位与技术选型:为什么偏要用Web技术栈硬啃这套规则
1.1 三国杀为什么适合做开源练手项目
很多人觉得三国杀不过是个卡牌桌游,规则能有多复杂?真把它拆开,复杂度比想象中大得多。仅仅是游戏进行中的一个摸牌阶段,就可能被各种技能插入改写,比如额外摸牌、弃牌重摸、牌堆顶判定、交给其他角色手牌等。这些机制交错起来,对状态管理、事件分发、数据一致性都是实打实的考验。
挑三国杀作为开源项目,最大的价值在于它的规则天然带“状态机”和“事件驱动”两大特性。每一回合都要经历准备、判定、摸牌、出牌、弃牌、结束这几个阶段,而几乎每个阶段都会被延时锦囊、武将技能、装备效果拦截和改写。要实现这样的规则,不能靠if-else堆,必须有清晰的架构去承接。
这个系列的第一篇已经说了总体的项目规划,这一篇重点聊105天之后实际落地的东西。眼下项目代码里前端部分约占四成,服务端规则引擎约占六成,这个比例基本稳定了很久。前端是纯表现层,负责播放动画、渲染手牌和操作按钮;所有规则判定都在服务端完成。
1.2 前端、后端、数据层的选型对比
项目最开始那几天,我先做了一轮技术选型对比,当时列了一张表,反复纠结了很久:
| 方案 | 优点 | 缺点 | 最终选择 |
|---|---|---|---|
| Unity + C# + Mirror | 游戏引擎成熟,网络库完善 | 前端包体大,不方便网页直接跑,迭代不如Web快速 | 否 |
| 微信小游戏原生 | 平台流量方便 | 调试链路长,规则引擎和平台耦合太多 | 否 |
| 前端React + 后端Node.js | 全栈同语言,TypeScript类型可以共享 | 实时性不如专用游戏服务器 | 是(最终采用) |
| 前端Vue + 后端Go | 后端性能好 | 两套语言,通信契约要手写维护 | 否 |
最后选的是前端React + 后端Node.js(TypeScript)的组合。理由很直接:类型定义只要写一份,服务端的卡牌数据结构可以直接生成类型声明供前端使用。三国杀这种回合制卡牌游戏对实时性要求没有射击类游戏那么高,WebSocket的延迟完全够用。同语言的好处在后来的迭代里体现得很明显——我改一个卡牌字段,前端和后端编译期就能暴露类型错误,不用靠联调发现。
数据层最开始用了MongoDB,后来发现对局数据根本不需要那么重的存储。现在改成JSON文件快照存盘,每局结束写一份对局记录,用于回放和调试。用户账号数据才用数据库存,而且做得非常简单。卡牌配置和武将配置全部是JSON配置文件,让“新增武将”这件事不需要改动服务端代码逻辑。
1.3 目录结构与模块边界
项目根目录下的核心结构大概是这样的:
src/ common/ # 前后端共享的类型定义、卡牌枚举、配置结构 server/ engine/ # 规则引擎:牌堆、状态机、技能注册表 network/ # WebSocket消息处理、房间管理 ai/ # 简单AI出牌策略 client/ pages/ # 页面组件 game/ # 对局中的Canvas渲染层这个结构是105天里反复调整出来的。最初我把规则判断散落在各个文件里,结果改一个卡牌效果要翻好几个文件。后来把所有“规则判定”收敛到engine目录,前端只负责“用户点了哪张牌、选了什么目标”,服务端收到之后做校验和结算,再把结果广播给所有人。
边界清晰之后,很多问题自然就消失了。比如“杀被闪避之后该不该触发某个技能”这种问题,根本不需要前端参与判断,服务端结算完直接广播结果。前端的职责只剩下把服务端下发的动作序列播放出来。
2. 牌堆、洗牌与随机性:一张判定牌的顺序都能让整局翻车
2.1 牌堆的构成与数据结构
标准的身份局牌堆由基本牌、锦囊牌、装备牌组成,总共108张。每种牌除了类型、名称、花色点数之外,还需要标注是否延时锦囊、是否可主动使用、是否可以响应等属性。这些属性直接决定了出牌阶段的按钮亮不亮。
牌堆在数据结构上我用了一个简单的“数组 + 索引指针”方案。牌堆是一个数组,抽牌时从当前索引往后取,索引越界时把弃牌堆洗回牌堆。这么做的好处是节省内存分配,而且可以完整记录每一局游戏里“哪张牌在第几次洗牌之后到了谁手上”,方便对局回放时逐帧复盘。之前也考虑过链表结构,但JavaScript里链表节点对象开销太大,实测下来数组指针方案在108张牌的场景下性能完全够用。
2.2 洗牌算法的选择:Fisher-Yates的意外优势
洗牌这件事看起来简单,但和三国杀的规则一结合就有讲究了。游戏里频繁出现“牌堆顶的牌翻开判定”这类操作,比如闪电判定、乐不思蜀判定。判定结果直接影响后续行为,所以牌堆的排列顺序就是游戏的一部分,不能是黑盒。
我采用的是Fisher-Yates洗牌算法,每次开局洗牌时生成一个随机种子。这个选择后续帮了大忙。带种子的随机数意味着同一局对局的牌堆顺序在服务端日志里是可复现的——某个Bug出现后,只要拿到当时的种子和一系列随机数调用次序,就能一比一重现场景。这一点在做回放调试时价值极大。
核心洗牌代码长这样,TypeScript版本:
function createSeededRandom(seed: number) { let state = seed >>> 0; return function() { state = (state * 1664525 + 1013904223) >>> 0; return state / 4294967296; }; } function shuffleDeck<T>(deck: T[], seed: number): T[] { const rand = createSeededRandom(seed); const result = [...deck]; for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; }这里用了线性同余生成器,实现最简单,统计均匀性在108张牌的场景下足够了。如果对随机性要求更高可以换用更复杂的伪随机算法,但说实话,对一局桌游来说,玩家感知不出这点差异。
2.3 弃牌堆回收与判定牌的细节
弃牌堆的回收逻辑有一个容易出错的点:判定牌和结算完毕的牌进入弃牌堆的时机。最初我把判定完毕后立刻把判定牌放进弃牌堆,后来发现不对——比如某技能可以在判定牌生效前将其替换,如果牌已经进弃牌堆,替换逻辑就很难写。
正确的做法是:判定牌先放在一个“待结算区”,等判定结果完全确定、所有响应技能结算完毕之后,才统一放进弃牌堆。“待结算区”的存在让整个判定流程在所有技能插手的中间态都能保持一致性。这个结构也是看了几个开源卡牌引擎的实现才学到的,处理这类“中间插入”问题特别有效。
弃牌堆回收洗回牌堆的时机也要注意。规则规定弃牌堆只有在牌堆抽空时才洗入,不能提前。若是某托管角色的自动出牌导致牌堆和弃牌堆同时为空,必须做兜底:直接从弃牌堆强制洗回。否则整个房间就会卡死,所有玩家都摸不到牌。这个兜底逻辑我会在后面讲联机超时的时候一起说。
3. 回合状态机:让每个阶段像一条流水线一样可控
3.1 为什么不用简单的if-else流程
三国杀的回合流程看上去就是六个阶段线性执行,但其实每一阶段都可能被跳过、被重复、被插入其他角色结算。如果用if-else硬写,代码会膨胀得很快,而且极难维护。比如“乐不思蜀”生效时跳过出牌阶段,“兵粮寸断”生效时跳过摸牌阶段,濒死结算时全场角色依次求桃,这些流程交织在一起,用普通代码写出来就是灾难。
我最终采用了经典的状态机模式。回合状态机的核心定义如下:
type Phase = | 'prepare' | 'judge' | 'draw' | 'play' | 'discard' | 'finish'; interface TurnStateMachine { currentPhase: Phase; currentPlayerId: string; turnOwnerId: string; skipFlags: Partial<Record<Phase, boolean>>; }每个阶段对应一个处理函数,阶段切换时触发事件总线上的对应生命周期事件。这样技能只要监听自己关心的事件就能插入流程,而不需要修改状态机本身的代码。
3.2 阶段切换与技能插入的协作方式
状态机里最核心的方法是nextPhase()。每次切换前,先检查skipFlags,判断当前阶段是否被跳过;然后触发onPhaseExit事件,进入新阶段后触发onPhaseEnter事件。技能系统在事件回调里可以通过返回特定标记来修改状态机的走向。
举个例子,一个技能让角色“额外进行一个出牌阶段”,实现方式就是在结束阶段里向状态机压入一个临时状态。这里我用了一个轻量的阶段栈:
class TurnFlow { private stack: Phase[] = ['prepare']; enterPhase(phase: Phase) { this.stack.push(phase); } exitCurrentPhase() { this.stack.pop(); } }因为真实的三国杀流程除了顺序执行,还允许“插入一个额外阶段”或“回到某一个阶段”,单向状态机不够用,阶段栈可以优雅地应对这些情况。实测下来,处理“张角的雷击连续判定”“多个角色濒死轮流求桃”这些场景都很清晰。
3.3 锁状态与阶段窗口:最容易被忽略的两个边界问题
阶段切换还有一个容易忽略的点——阶段跳转的“时机窗口”。规则里每个阶段之间都有严格的时间点,某些技能只能在一个特定瞬间发动。比如“回合开始时”和“准备阶段开始时”严格来说不是同一个时机,虽然很多玩家感觉不到区别,但规则引擎必须区分。我在事件总线上给这些时机单独编了号,让技能可以精确指定监听哪个时机,避免误触发。
锁定技和普通技能的结算顺序也很讲究。有些锁定技是强制生效的,不能选择不发动;普通技能玩家可以选发动或不发动。我的做法是把锁定技放进一个高优先级队列,在执行阶段自动结算,普通技能队列则等待玩家确认。这两个队列共用同一个事件触发点,但执行顺序永远先锁定技后普通技能。
4. 技能系统:不用改代码也能不断新增武将的注册机制
4.1 事件总线架构与技能触发点设计
三国杀规则里几乎每一次行为都可能触发技能:摸牌前、摸牌后、造成伤害前、造成伤害后、进入濒死、死亡结算等等。这些触发点用传统函数调用方式去“显式调用”根本做不完,你必须把它设计成事件广播。
我定义了一批trigger事件名称,比如:
| 事件名 | 触发时机 | 携带数据 |
|---|---|---|
| round_start | 回合开始时 | 当前回合角色ID |
| before_draw | 摸牌阶段开始前 | 摸牌数量 |
| after_draw | 摸牌完成后 | 摸到的牌ID列表 |
| before_damage | 伤害结算前 | 伤害来源、目标、点数 |
| after_damage | 伤害结算后 | 实际造成的伤害值 |
| dying | 角色进入濒死 | 濒死角色ID、当前体力值 |
| before_death | 角色死亡前 | 死亡角色ID |
| card_played | 任意角色使用牌后 | 使用牌ID、目标列表 |
技能模块注册时声明自己监听哪些事件,事件总线根据注册表统一分发。这样新增一个技能,本质就是“监听若干事件 + 定义回调函数”,和游戏框架本身解耦。
技能配置的JSON长这样:
{ "id": "skill_example", "name": "示例技能", "trigger": [ { "event": "before_draw", "priority": 100 } ], "script": "skillExample.js" }script字段指向对应的技能逻辑文件,服务端启动时扫描所有技能脚本并注册到事件总线。新增一个武将,只需要新增一个JSON配置和一份技能脚本,重启服务即生效。之前花了很多时间把所有武将的手动if-else判断替换成这套事件机制,替换完之后代码量反而少了很多,新增武将的复杂度大幅降低。
4.2 技能优先级与结算顺序
多个技能同时监听同一个事件时,顺序问题不可回避。比如“造成伤害时”可能有三个技能都想插入,谁先谁后会影响结果。我给每条监听配置增加了优先级字段,数字越大越先执行。同优先级的情况下,按照“当前回合角色 > 其他角色按座位顺序”的规则排列。
这个规则乍一看很简单,实际上要处理很多盘根错节的场景。举例来说,A对B造成伤害,A有一个伤害加深技能,B有一个伤害减免技能,两个技能都监听before_damage。如果A的优先级高,先执行伤害加深,再执行伤害减免,最终可能伤害被减免到1;如果B的优先级高,先减伤再加深,结果可能完全不同。设计优先级表时必须逐条对照规则集里的描述,不能想当然。
4.3 技能状态快照:防止“结算到一半技能没了”
还有一个隐藏很深的坑:技能结算过程中的“状态快照”。假设某个技能的效果是“本回合内,所有角色造成的伤害+1”,这个buff需要持续整个回合。但如果释放该技能的角色在结算过程中死亡了,buff还在不在?规则里大多数情况下buff仍然生效。
我的做法是:每个技能效果独立存进一个effect对象,包含来源角色、持续时间、效果数据,由事件总线统一管理。效果的生命周期只跟“持续时长”绑定,不跟来源角色是否存活绑定。也就是说,技能是否继续存在取决于效果本身,而不是角色状态。这个设计让“死亡结算期间各种buff仍生效”的场景处理起来非常干净。
5. 联机通信、出牌超时与断线重连:服务端权威模型的实际落地
5.1 通信协议与消息结构
联机部分我没有用现成的游戏框架,直接基于WebSocket手写了一套极简协议。消息结构如下:
interface GameMessage { seq: number; // 消息序号,用于客户端请求去重 type: 'request' | 'response' | 'broadcast'; action: string; // 动作名,如 playCard payload: any; // 具体数据 ack?: number; // 如果是响应/广播,对应哪条请求 }客户端的所有操作都以request形式发出,服务端校验后以broadcast广播给房间内所有人。这里面有一条设计原则:客户端永远不直接修改游戏状态,所有操作都必须经过服务端。简单说,客户端只提交“我想出这张牌,指定这个目标”,服务端说“允许”,然后广播“谁出了什么牌”,全场播放动画。
这套“服务端权威”的模型最大的好处是防作弊和防不同步。任何一个玩家看到的游戏状态,都是服务端广播出来的结果,不存在各客户端自己算自己版本的情况。开黑时出现“我看到的和你看到的不一样”这种问题,从根本上就不可能出现。
5.2 出牌超时管理
三国杀联机里最破坏体验的问题就是“有人挂机”。我在服务端做了统一的超时管理,每个需要在客户端做决定的时刻,都创建一个定时器:
- 主动出牌阶段:默认60秒
- 响应阶段(如是否出闪):默认20秒
- 濒死求桃等紧急结算:默认15秒
超时后服务端自动调用AI替该角色做出选择。这套策略并不复杂,AI出牌逻辑就是一个优先级表:优先出桃自救,其次出杀攻击,最后弃牌。虽然是“弱智AI”,但至少能保证对局继续进行下去,不会因为一个人掉线导致全场卡死。
定时器管理最麻烦的是“服务器重启恢复”。因为进程一旦重启,内存里的定时器全部丢失。我的做法是每局的关键状态在每次同步时都写入一个本地快照文件,重启后从快照恢复,超时时间重新计算。这样即使服务端崩了,房间也不会永久卡死。
5.3 断线重连的思路
在做断线重连之前,我原本想的是“玩家断线后,AI托管直到玩家回来”。但实测发现一个问题:玩家可能只是网络波动几秒钟,回来发现AI已经替自己把关键牌打出去了,体验极差。
于是改成“短断线快速重连,长断线AI托管”的策略。玩家断线以后,在前15秒内重连,直接回到原房间,游戏状态无缝衔接,这段时间AI不会乱动;超过15秒还没回来,AI才开始接管操作。为了支持快速重连,每局会在内存里保留所有玩家的状态快照,玩家重连时把整局同步数据一次性下发。这个功能调了两周才算稳定,主要是状态快照和实时广播之间偶尔会有一步之差,最终通过给每个广播消息加全局递增序号解决了。
6. 实测数据与踩坑记录:优化到第105天时最值得说的几件事
6.1 对局性能:一局下来资源占用有多少
项目做到80天左右的时候,我开始认真做性能测试。用一台8核16G的服务器模拟了33人对局并发放AI托管,数据如下:
| 指标 | 数值 |
|---|---|
| 单局平均时长 | 约14分钟 |
| 单局WebSocket消息总量 | 约4000~6000条 |
| 单人前端Canvas帧率 | 稳定在55fps以上 |
| 服务端单局内存峰值 | 约180MB |
| 牌堆洗牌耗时 | 小于1ms(108张) |
这个数据说明方案完全够用。卡牌游戏和动作游戏不一样,不需要每帧同步所有状态,消息量主要集中在“出牌广播”和“阶段切换”上,峰值也能控制在极低水平。瓶颈反而在浏览器端Canvas绘制卡牌动画上,60fps在复杂特效弹出时会偶尔掉到40fps左右,不过这主要取决于玩家机器性能。
6.2 踩坑记录一:判定牌乱序导致闪电必定劈自己人
这个Bug让我印象最深。最初判定流程里,我把“从牌堆顶翻牌”直接实现为“抽一张牌”,然后把这张牌放进待结算区。表面看起来没错,但后来频繁出现一个怪异现象:某个角色判定闪电,翻出来的牌点数几乎总是大于12。排查了两天,最后发现是“抽牌”动作复用错了一个函数——抽牌函数默认把牌放进了手牌,而判定牌应该放进待结算区。手牌数量增加后,角色的判定结果被后续洗牌和摸牌逻辑干扰,牌序完全变了。
修复很简单,就是把判定动作和摸牌动作彻底拆开成两个独立函数,判定牌走单独的分支。但这个坑说明了一个架构问题:规则引擎里虽然“摸牌”和“判定翻牌”在物理表现上很像,但在规则语义上完全是两回事,不能贪图代码复用把它们做成同一个函数。
6.3 踩坑记录二:Canvas渲染内存泄漏
前端这块,最头疼的是Canvas的内存泄漏。播放卡牌特效时我用了一个粒子库,每一次特效都新建一批粒子对象,但没有在动画结束时清理。连续玩三四局之后,页面内存能涨到300MB以上,最后浏览器直接崩溃。
排查过程很简单,Chrome的Performance面板录制一段特效播放过程,发现粒子数组只增不减。修复方式是给每个粒子绑定一个maxLife属性,动画帧里统一检查并及时移除结束的粒子。修完之后,连续挂机一晚上,内存稳定在200MB以内。
6.4 踩坑记录三:隔一个房间的消息串线
联机开发里还出过一个特别隐蔽的Bug。测试两个房间同时开局时,A房间的广播偶尔出现在B房间的日志里。最后发现房间对象在内存里是Map存储,但消息回调函数闭包里不小心引用了循环变量。修复方式是房间ID在创建时用let块级变量保存,闭包捕获当前值。这个Bug排查难度不大,但很提醒人:跨房间的变量隔离,在编码阶段就要注意。
7. 后面一个月准备做的事:脚本热更新、回放系统与观战模式
当前版本跑通了对局全流程,但离一个完整的开源项目还差几块拼图。接下来一个月优先级最高的是“对局回放系统”。因为服务端已经记录了每局所有广播消息的时间戳和牌堆种子,理论上重放这些消息就能完整还原一局比赛。回放系统做出来对调试复杂技能交互特别有用,也能让使用者方便地录屏发视频。
然后是技能脚本的热更新。当前新增武将需要重启服务才会生效,开发期频繁重启还能忍,但开源出去以后,如果别人只想加个新武将还要重启整个服务,体验就太差了。计划改成技能脚本独立加载、修改后自动热替换,不中断正在进行的对局。这个技术上不难,但要注意正在结算中的技能不能中途被替换掉,需要设计一个“安全替换窗口”。
观战模式的优先级排在最后。观战者只需要订阅房间的广播消息,不需要发送任何操作请求。实现上比玩家端更简单,主要工作量在UI布局和消息过滤上。
我自己的开发节奏是每天推进一个小功能点,然后跑一遍全量规则测试用例。目前游戏里46个基础武将的技能全部覆盖了自己的测试用例,虽然整套用例不算多,但每次改动后能立刻发现回归问题,这比任何文档都管用。如果看到这儿的你也想动手做一个类似的规则引擎项目,我的建议只有一条:先把事件总线和状态机搭好,再开始做技能。地基没打好,后面加多少功能都会在地震时塌给你看。