
1. 第105天这个“365天三国杀开源”到底做到哪一步了看到“三国杀开源系列之二105365”这串编号式的标题第一反应大概率是乱码。它其实是我给自己挖的一个坑每天固定抽一点时间把三国杀这个桌游从零到一实现成开源程序跑在浏览器里。365天是一条硬时间线“之二”表示系列第二篇而“105365”就是当前节点的坐标——整个项目已经走到第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: Recordstring, 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天路程后面的网络同步和房间系统会是另一个大坑但至少今天核心引擎已经扎实地站了起来。