news 2026/10/17 1:03:56

开源三国杀引擎开发105天:事件驱动规则引擎与状态机设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
开源三国杀引擎开发105天:事件驱动规则引擎与状态机设计

1. 第105天,这个“365天三国杀开源”到底做到哪一步了

看到“三国杀开源系列之二105@365”这串编号式的标题,第一反应大概率是乱码。它其实是我给自己挖的一个坑:每天固定抽一点时间,把三国杀这个桌游从零到一实现成开源程序,跑在浏览器里。365天是一条硬时间线,“之二”表示系列第二篇,而“105@365”就是当前节点的坐标——整个项目已经走到第105天。

为什么值得关注这个时间点?一个开源游戏引擎,最容易死在“需求太大”和“遍地开花却都不结实”这两件事上。很多个人项目会停在第14天因为失去新鲜感,或停在第200天因为回头发现代码结构已经乱到没法继续。这个项目走到第105天,虽然离真正的网络对战版还有距离,但核心引擎已经稳定到可以支撑整局游戏从头到尾跑完,我觉得这本身就值得记录一笔。

先说清楚现在能做什么。项目已经实现了:基础牌、锦囊牌、装备牌的完整数据定义;一套与界面完全无关的规则引擎,在命令行里就能跑;浏览器端能展示手牌和玩家状态;还有一套自动对战脚本能批量模拟对局。换句话说,“游戏能被真实地玩起来”这件事,已经完成了大致80%,剩下的主要是网络同步、房间管理和界面体验。作为系列第二篇,这篇文章重点讲规则引擎和核心流程是怎么设计出来的,方便你在没有UI干扰的情况下理解整件事。

1.1 一年期限不是噱头,是为了防烂尾

很多人做开源软件,一开始靠兴趣驱动,前两周特别兴奋,功能飞速堆积。到了第三周,只要遇到一个稍微复杂的问题,就会开始告诉自己“先放一放”,然后这个项目就永久地停在“放一放”的状态。我给这个项目定下365天,目的就是给“放一放”设置一个反向的拉力。

具体的日程规划是这样的:

时间区间计划内容当前状态
第1-60天牌表整理、基础UI框架、项目脚手架已完成
第61-150天规则引擎、本地多人对战逻辑本篇对应阶段
第151-240天网络房间、状态同步、观战机制未开始
第241-365天平衡性调整、文档完善、对外发布未开始

会不会太慢?如果是一个商业团队,这个速度确实不够快。但单人开源项目,每天投入的时间有限,能持续干一年比短期内爆肝两周重要得多。把平衡性调整放在最后也是最稳妥的:卡牌游戏是数据驱动型项目,只要规则引擎稳定,改数值和改卡牌效果都不会伤筋动骨,前期没必要把精力耗在平衡性上。

1.2 第二篇文章的核心边界

第一篇发布时,项目只能展示“这是一张牌”“你点一下这个按钮手牌会少一张”,本质上还是一个静态的交互Demo。这篇文章要解决的问题是:让一局真正的四人游戏能在本地跑通,哪怕没有界面也能跑。

具体来说,第二篇结束时必须覆盖这些内容:

  • 身份分配,包括主公、忠臣、反贼、内奸四种阵营的覆盖逻辑;
  • 完整回合流程,从回合开始到回合结束六个阶段全部转起来;
  • 基本牌的使用与响应,“杀”“闪”“桃”的结算链路;
  • 距离与攻击范围的计算,武器和坐骑装备生效;
  • 锦囊牌进入响应链时如何插入结算栈;
  • 一局游戏结束时,胜负判断准确且不误杀“反贼胜利”这类边界情况。

UI暂时不是重点,因为规则引擎只要能跑,就可以被任何前端调用。先把骨架立住,后面做界面才会舒服。

1.3 技术栈与目录结构

技术栈最终选了TypeScript + Node + Vue + Vite。没有选极客风格的纯命令行走天下,也没有选重量级游戏引擎,原因很朴素:这个项目需要同时照顾后端规则和前端展示,TypeScript可以做到两端共用一个核心,而Vue用于页面也只是顺手。

目录结构我划分如下:

src/ cards/ // 卡牌定义文件(JSON + 部分脚本) core/ // 规则引擎,和框架无关,纯逻辑 server/ // 本地socket服务,预留网络对战 web/ // 浏览器端Vue界面 tests/ // 自动对战、随机模拟、回归测试

为什么要单独把core拆出来?因为规则引擎最怕被UI夹住。如果游戏逻辑写在组件里,测试会变得极其痛苦,你没法把“出一张杀”拆成一个可复现的单元。放在一个纯函数包里,几百行的测试脚本就能覆盖一整套游戏流程。

2. 规则引擎设计上最重要的一个决定:用事件流而不是改字段

如果只学一个架构概念,我会推荐“事件溯源”。游戏引擎的核心无非是“状态变化”,但状态变化有两种写法:一种是直接修改内存里的对象字段,比如player.hp -= 1;另一种是把每一次变化记录成事件,比如{ type: 'damage', target: 'player_3', value: 1 },然后由事件来推导状态。

三国杀这类回合桌游特别适合事件溯源,因为一个操作会引发一串连锁反应。玩家A对玩家B出“杀”,B可能出“闪”,A可能再打出一张“无懈可击”,旁边还有角色技能在触发。如果只改字段,你很难回答“刚才到底发生了什么”,更别提解决“观战如何同步”“断线重连怎么恢复”这些问题。

2.1 事件日志如何驱动游戏状态

我最开始写的规则引擎,确实就是一堆直接改对象属性的逻辑。当时想着简单,但跑到“酒杀”和“藤甲”这种组合效果时,发现光靠属性堆叠根本分不清伤害来源是几次攻击,连“该由谁摸牌”都会出错。

换成事件流之后,一切都变了。每次操作都会产生一个带时间戳的事件实例:

interface GameEvent { id: string; type: 'draw' | 'play' | 'damage' | 'discard' | 'skill_trigger'; playerId: string; payload: Record<string, any>; timestamp: number; }

所有规则逻辑只做一件事:接收一个事件,检查是否满足触发条件,然后决定是否产生新事件。这样状态机是一个纯粹的事件处理函数,更容易测试,也更容易回放。

2.2 为什么卡牌数据要保持纯数据

卡牌定义我用JSON来描述,不把逻辑写进JSON,但也不让逻辑散落在各处。一个典型的“杀”定义大概是这样的:

{ "id": "sha", "name": "杀", "type": "basic", "subtype": "attack", "suit": ["spade", "heart", "club", "diamond"], "rank": ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"], "target": "enemy", "range": 1, "effectModule": "basic-attack" }

注意effectModule字段,它指向一个独立的函数模块,而不是在JSON里写一段让人头大的函数。这样做的意义是:新加一张卡牌不需要新增代码,只要在JSON里描述属性,再复用已有的效果模块。比如“火杀”只是“杀”的subtype变成“fireAttack”,逻辑可以共用“basic-attack”,只有结算时候的判断多一步“火焰伤害是否被防具抵挡”。

2.3 前后端共用同一套逻辑

很多人做联机卡牌游戏,最容易出现的坑是前端一套规则、后端一套规则。浏览器为了展示需要知道“伤害结果”,如果前端和后端各写一套计算逻辑,必然会出现“前端显示你死了,后端认为你还活着”这种离奇Bug。我干脆让所有规则都跑在同一个core目录下,前端页面只负责渲染,不参与胜负判断。

实际效果是,浏览器端调用的规则逻辑和后面做网络版时跑在服务端的逻辑是完全一致的。测试脚本在Node里直接import同一套代码,从源头上消灭“两套逻辑打架”的问题。

3. 把三国杀的回合流程做成状态机

回合制游戏天然适合状态机。一个玩家的回合被拆成六个固定阶段,顺序不能乱,每个阶段有明确的进入条件和离开条件。

3.1 六个阶段的显式建模

我用一个枚举把阶段固定下来:

export enum Phase { Start = 'start', Judge = 'judge', Draw = 'draw', Play = 'play', Discard = 'discard', End = 'end' }

每个阶段都是一个独立的处理单元。回合开始阶段处理一些技能触发,比如某些角色的锁定技会在阶段开始时生效;判定阶段处理延时锦囊牌;摸牌阶段给当前玩家摸牌;出牌阶段允许玩家打出可用的牌;弃牌阶段先检查手牌是否超过体力上限,超过则强制弃牌;回合结束阶段进入下一位玩家的回合。

为什么要显式建模?因为真正常见的Bug往往不是“阶段顺序错了”,而是“某个阶段被跳过了”。我早期测试经常发现,有人摸牌阶段摸完牌,直接跳到别人的回合,出牌阶段完全没有执行。用枚举之后,每个阶段都加上assert判断,一旦顺序不对立刻报错,问题从源头就被堵死了。

3.2 出牌阶段的临时状态

三国杀有很多限制,比如每个回合通常只能使用一次“杀”。这种限制不能放在玩家对象里,否则会累积到下一回合。我专门维护一个playState对象,只在出牌阶段创建:

interface PlayPhaseState { usedShaCount: number; maxShaPerTurn: number; usedCards: string[]; }

出牌阶段开始时创建,阶段结束时销毁。这样一个状态只在当前回合存活,不会污染后面的回合。如果某张锦囊牌允许“你本回合可以多用一次杀”,只需要在出牌阶段内修改playState.maxShaPerTurn。这种设计让技能叠加变得非常直观。

3.3 手牌、装备区、判定区这三个容器怎么处理

游戏里牌会在不同区域移动,手牌、装备区、判定区、弃牌堆,本质上都是“牌的容器”。我写了一个统一的移动函数:

function moveCard(cardId: string, fromZone: Zone, toZone: Zone): void

所有代码必须通过这个函数移动牌,不允许任何人直接调用push或splice往容器里塞牌。原因很简单:如果每条逻辑都各自操作数组,你没法追踪“这张牌现在在谁手里”。统一移动之后,日志里每次移动都会记录一条事件,回放和查Bug都方便得多。

这里还有一个容易踩的坑:手牌是有顺序的,装备区是有固定槽位的。装备区的马分“进攻马”和“防御马”两格,武器占武器格,防具占防具格。如果用数组存装备区,“换武器”时旧武器进弃牌堆的操作会变得特别别扭。我干脆用对象来存装备区:

equipmentZone: { weapon: string | null; armor: string | null; offensiveHorse: string | null; defensiveHorse: string | null; }

这样装备区的逻辑就变成“如果槽位已有牌,先弃掉旧牌,再装备新牌”,顺序清晰,不会错乱。

4. 回合外结算:从“出杀”到“闪”“桃”的完整链路

光有回合流程还不够,卡牌游戏的核心在回合外的响应链。一个玩家出“杀”,另一个玩家是否出“闪”?旁边有角色被“酒”杀命中,伤害是2点,是不是有人要出“桃”救?这些响应必须在一个事件循环里自然转起来。

4.1 距离与攻击范围的计算模型

距离是三国杀里一个特别容易被算错的维度,尤其是加上了坐骑和武器。默认规则里,相邻玩家距离是1,用环形座位来计算,比如5人局里,你和顺时针第2个玩家距离是2。而“杀”的基本距离是1,如果没有武器或者“-1马”,你只能杀到相邻座位的人。

距离计算的代码不长,但容易出错:

function getDistance(game: Game, fromId: string, toId: string): number { let distance = Math.abs(game.seats[fromId] - game.seats[toId]); distance = Math.min(distance, game.playerCount - distance); distance -= getOffensiveHorseValue(game, fromId); distance -= getDefensiveHorseValue(game, toId); return Math.max(1, distance); }

这里有个细节:进攻马减的是“你到别人的距离”,防御马加的是“别人到你的距离”,它们不是同一个方向。早期我为了省事,把两种马合并在一个槽位里,结果出现“我装了防御马,我杀别人反而也被加距离”的离奇情况。后来老老实实拆成两个独立字段,才能准确模拟两个方向的衰减。

4.2 等待玩家响应时的请求锁

这是网络对战版的关键,本地版也不能回避。玩家A出“杀”,玩家B手上有“闪”,这时候服务端不能阻塞等待B的输入,否则整个程序会卡死。正确做法是进入一个“等待响应”状态,并向当前玩家发出请求:

interface PendingRequest { id: string; type: 'ask_response'; askTo: string; options: string[]; timeout: number; }

在等待期间,除了被请求的玩家,其他人发的指令一律进入待处理队列,但不执行。这样做的意义是让引擎保持异步安全。我在本地测试时直接模拟“玩家A出杀—玩家B点闪—A伤害被消除”这条链路,用简易的事件循环来控制,效果非常接近真实网络对战。

超时处理也要设计。如果被请求的玩家没有任何响应,比如手里没有“闪”,客户端会立刻选择“不闪”并提交超时;如果客户端断连,服务端也需要一个默认响应。

4.3 锦囊和技能的结算队列:不要用递归

稍微复杂一点的卡牌,比如“南蛮入侵”,会对每个其他玩家依次询问“是否出杀”。而“无懈可击”又可以在任何锦囊牌生效前插入结算。如果单纯用递归调用,会让调用栈越叠越深,“无懈可击抵消无懈可击”这种场景直接让栈溢出。

我用了一个显式的结算队列:

const resolveStack: ResolveNode[] = [];

每当一张锦囊牌需要结算,就往栈里压入一个节点。每个节点包含“被执行对象”“响应选项”“当前状态”。一个节点结算完毕,再弹出下一个。这样即使“无懈可击”套“无懈可击”,也只是往栈里再压一层,不会造成函数递归爆炸。

这应该是这次开发中让我最有成就感的设计。它把复杂的连锁反应变成了一个线性可调试的过程,日志里能看到每个节点何时入栈、何时出栈,所有顺序一目了然。

5. 实操过程:把一局四人游戏跑起来

前面讲了设计,这一节看实际运行效果。我在本地实现了一个极简的“自动托管”客户端:四个AI玩家,没有界面,通过命令行脚本跑游戏,最后输出整局的行动日志。

5.1 最小可运行Demo

最简单的跑法是在Node里直接调用核心引擎:

const game = new Game({ playerCount: 4, ai: true }); game.init(); for (let i = 0; i < 500; i++) { if (game.isEnded()) break; game.tick(); } console.log(game.logs);

game.tick()是事件循环的核心驱动。每一tick,当前阶段的AI玩家会选择行动,产生事件、执行结算,然后推进状态。500个tick对四人局来说通常足够了,正常一局在40~80个tick左右结束,超过500说明有死锁。

跑通第一局时我确实兴奋了一阵,因为日志输出里能看到“摸牌、出杀、闪、伤害、弃牌、进入下一位”的完整链路。如果你也想复现,建议先从三人局开始跑,因为三人局最快碰到“出杀—闪—桃”的循环路径。

5.2 批量模拟对局找死锁

单局跑通不代表能稳定运行。我做了一个批量模拟脚本,一次跑10000局,统计数据:

  • 平均回合数是否在合理区间(5~15回合)
  • 主公胜率是否和反贼接近,不会出现一边倒
  • 是否有游戏在中途卡死

批量模拟的价值远超我的想象。它找出过两个特别隐蔽的Bug:一个是某锦囊牌在结算时对象已经死亡,却还在等待他响应;另一个是“判定牌”被误放进弃牌堆,结果摸牌阶段又偶尔会摸到判定牌。这两个问题单局跑基本不会触发,但一万局随机模拟中必然暴露。

5.3 快照排障法

当批量模拟卡死时,直接打印日志往往信息量太大。我的方法是每20个tick输出一次全局快照,内容包括:

  • 每个玩家的位置、体力、手牌数
  • 当前阶段和等待响应的请求
  • 结算队列的长度
  • 当前事件消息

快照就像给游戏做CT扫描。卡死时,看快照能直接定位到“某个玩家明明该出牌,却一直没有响应”,再回去找对应的PendingRequest,往往几秒钟就能找到问题。

6. 这105天的经验与印象深刻的坑

最后聊聊这一百多天里踩过的坑,以及一些真正起作用的习惯。

6.1 牌堆与体力上限的边界管理

体力上限和手牌上限之间有关系:弃牌阶段结束时,手牌不能超过当前体力值。早期处理“被伤害后体力上限变化”的效果时,我把手牌上限直接绑定当前体力,结果出现一个尴尬情况:玩家被扣血后,手牌瞬间被强制弃牌,但这在三国杀的规则里不是正确的判定顺序。

正确做法是:只有到达弃牌阶段,才重新计算手牌上限并强制弃牌。受伤的瞬间不应该触发弃牌,除非是特殊技能描述。这个边界让我重新意识到“规则状态的检查时机”比“规则本身”更重要。

6.2 以后做网络版时,我会毫不犹豫选全量状态

网络对战里,状态同步是一个绕不开的问题。我最早的计划是“只同步增量事件”,觉得这样省流量、更高效。但在本地先把引擎写完以后,我的改变是:先同步全量状态,每回合结束后把完整游戏状态发给所有客户端,事件流只作为辅助回放数据。

为什么这样选?因为桌游对帧率不敏感,一秒钟就算同步一次完整状态,流量也完全能接受。而全量状态换来的是极低的开发复杂度:客户端拿到状态后直接渲染,不需要去“合并一系列事件”。对于我这个体量的小项目,简化比优化重要得多。

6.3 每日提交与文档同频更新

这个项目能稳定走到第105天,有个习惯帮了大忙:每完成一个小的可运行里程碑,立刻提交代码,并同步更新项目README里对应的“已完成”列表。不要等“功能完整那天”再写文档,因为那天永远不会来。

我现在打开项目文档,可以准确知道“第多少天完成了哪个模块”。这种记录不仅是给访客看,更是给未来的自己看。一个人写代码时间久了,很容易忘记“为什么当初要这么设计”,文档能帮你把每个决策的背景固定下来。

说了这么多,最想传递给看到这篇文章的朋友的其实很简单:把一个开源项目当成一个长期日拱一卒的过程,远好过短暂冲刺后烂尾。三国杀这个项目还有260天路程,后面的网络同步和房间系统会是另一个大坑,但至少今天,核心引擎已经扎实地站了起来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 1:02:54

储能电池主动均衡方案:MP2642GR-Z与MKV42F64VLH16实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:01:58

云盘扩容后df -h不变?三步扩容法与文件系统扩展全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:01:50

基于MP2642GR-Z与STM32F405RG的两串锂电池主动均衡方案设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 0:56:47

Mypal3深度指南:给Windows XP老系统续命的Firefox分支与调优

“Mypal3”这个名字&#xff0c;老折腾系统的人应该不陌生。它就是那批坚持给老系统续命的浏览器分支之一&#xff0c;专为那些跑不动现代浏览器、又被官方彻底抛弃的老机器准备的。Mypal3&#xff08;9&#xff09;这个版本号&#xff0c;你可以把它看作是这条分支线里一个非常…

作者头像 李华
网站建设 2026/10/17 0:43:07

云计算存储基础全解:从DAS/SAN/NAS到RAID与虚拟化

存储基础在云计算里的分量&#xff0c;很多初学者是低估的。我当年备考HCIA云计算的时候&#xff0c;一开始把精力全放在虚拟化、网络、容器这些看起来"更高级"的内容上&#xff0c;结果一到存储相关的题目就卡壳。后来把存储基础这块彻底捋清楚之后&#xff0c;才发…

作者头像 李华
网站建设 2026/10/17 0:30:43

AI工具平权时代,真正拉开差距的是这四种稀缺能力

最近一段时间&#xff0c;我身边几乎所有人都在讨论AI工具。聊天群里天天有人分享新出的提示词&#xff0c;短视频上不断刷到"三分钟用AI做出爆款"&#xff0c;公司内部更夸张&#xff0c;连搞行政的同事都在用AI写周报。一个很现实的问题摆在每个人面前&#xff1a;…

作者头像 李华