资讯详情

从base36到zlib:手写一个麻将牌谱解析与复盘工具

📅 2026/10/10 6:42:51 | 华诺云谱 👁 阅读
从base36到zlib:手写一个麻将牌谱解析与复盘工具
简介一款面向雀魂玩家与麻将数据爱好者的跨平台牌谱分析工具可在国服、日服、国际服下解析四人麻将牌谱并参考天凤牌谱解析项目实现了多数数据维度目前仅缺失被鸣牌与门清听牌大类。压缩包仅1.18MB共74个文件以JavaScript脚本与C源码为主辅以JSON配置、HTML/CSS结果展示页面及YML自动化构建配置便于二次开发或直接部署运行。已有6659人浏览学习适合有一定麻将规则基础、希望量化自身对局的进阶玩家。包内包含跨平台可执行程序对应源码、结果展示网页及天凤凤凰桌参考数据可直接生成分析结果并与高层次玩家数据对比附带说明文档与配置文件便于跟踪自身薄弱环节或在此之上扩展更多分析项目。1. 一副牌谱注解出整局对战这个HTML工具拆的是什么打了几百场雀魂往往只记得自己胡了几把大的却说不清哪几手牌打得犹豫、哪几局是硬送。雀魂的牌谱是一串压缩又编码过的长字符串直接拿去复盘根本没法看。这份 MajsoulPaipuAnalyzer 资源解决的就是这个痛点把一串牌谱链接还原成一个可读的 HTML 分析页让手牌、摸打、结算、役种一目了然。它适合两类人一类是想复盘自己牌风的普通玩家另一类是研究复式牌谱结构的开发者。我拆完后的总体感受是工具本身逻辑不算复杂但解码链和判定表才是它真正的价值所在这也是我写这篇笔记的原因。2. 解码链清晰立住后HTML 单页为什么能扛下全部分析2.1 牌谱字符串不是加密是压缩与编码的叠加拿到一个牌谱链接时大多数人第一反应是“这是不是一串加密数据”。我第一次拆这个资源时也以为要解某种算法结果顺着资源里的解码逻辑走了一遍才发现它压根没有加密走的是一条非常朴素的链路原始 JSON → zlib 压缩 → base36 编码。为什么是 base36而不是更常见的 base64因为牌谱链接是要放进 URL 里分享的base64 字符集里带着和/放进 URL 还要额外转义。base36 用 0-9 加 a-z 共 36 个字符天然避开 URL 保留字符压缩后的二进制数据转成 base36 输出长度虽比 base64 略长但胜在链接可以直接复制、粘贴、不被截断。理解这条链路之后再去看资源里的解码函数就非常顺了先做 base36 解码得到压缩字节流再做 zlib inflate 还原出 JSON 文本最后 JSON.parse 成可操作的数据结构。整个过程没有任何密钥参与牌谱内容的“不可读”只是压缩编码的副作用不是安全设计。2.2 解码器三步还原出比赛记录资源里解码核心封装在一个函数中我用 JavaScript 把整条链路复刻了一遍。以下是核心代码去掉 UI 部分后其实只有三个动作// 1. base36 字符串还原成字节数组 function base36ToBytes(str) { const chars 0123456789abcdefghijklmnopqrstuvwxyz; let big 0n; for (let i 0; i str.length; i) { big big * 36n BigInt(chars.indexOf(str[i])); } // 转成小端字节序的 Uint8Array const bytes []; while (big 0n) { bytes.push(Number(big 0xffn)); big 8n; } return new Uint8Array(bytes.reverse()); } // 2. zlib 解压浏览器原生 DecompressionStream 不保证可用这里用资源内置的 inflate 实现 async function decodePaipu(encoded, inflate) { const compressed base36ToBytes(encoded); const jsonText await inflate(compressed); return JSON.parse(jsonText); }这段代码里的 base36ToBytes 用了 BigInt 做逐位乘法因为牌谱字符串可能长达数百字符普通 Number 在超过 2^53 时会丢精度不用 BigInt 直接算会导致字节流错位。inflate是一个注入的解压函数资源里用的是内置的 zlib 实现把解压逻辑抽成参数而不是写死在解码函数里是为了方便在浏览器和 Node 两个环境复用同一份解码代码。参数层面有两处值得改一是chars字符串的顺序必须与编码端完全一致官方牌谱用的是0-9a-z顺序如果从某个预览工具里复制了带大写字母的变体需要先做 toLowerCase二是解压后的 JSON 里藏着data字段其中data.cont才是真正的对局动作数组初看这个 JSON 时别被外层的大量元信息带偏。2.3 为什么这份资源选 HTML 而不是 Python/Node拆这份资源之前我先想过另一个方案用 Python 写个脚本requests 拉牌谱、解压、统计最后输出到一个 markdown 文件。但真跑起来就发现两个问题一是牌谱字符串经常出现在网页分享的 URL 参数里手动复制总带上前缀脚本得先做字符串清洗才能进解码器二是输出结果是静态的想按巡目翻看每一手的摸打记录文本形式远不如页面形态直观。这个资源选择 HTML 单页是合理的。全部逻辑跑在浏览器里没有后端依赖拿到文件后双击就能用。对普通玩家来说这意味着不需要装 Python、不需要配环境粘贴链接就能看到结构化复盘对开发者来说整个牌谱的解析与渲染逻辑都在一个 HTML 文件里剥离掉 UI 部分就得到一份可移植的解析器想接进自己的复盘工具也方便。边界也要说清楚HTML 单页不适合做大样本统计。浏览器端跑全量解析时如果一次性复制一百局牌谱进去页面会明显卡顿主线程被解析任务占满。这个工具的定位是“单局深度复盘”不是“批量数据挖掘”真要做批量分析把解码函数抽出来扔进 Node 脚本才是正路。3. 统计面板背后的判定逻辑把手牌还原与结果判定讲透3.1 庄家、自风与宝牌指示结算前的三张前置表解压后的 JSON 里字段不算多但每个字段都对应一个需要在渲染前算好的“表”。第一张表是座位表data.players数组里按座次存放玩家信息其中seat表示座位编号第二张是自风表根据data.config里的dealer字段确定庄家座次后再按东南西北循环推算出各家自风第三张是宝牌表data.cont的开头几条动作会包含doras字段里面是宝牌指示牌的牌值。这三张表必须在渲染任何牌河、任何统计之前全部准备好否则后续每一步判定都要回查原始动作流。资源里是把这三张表放在一个parsePreTables()函数里统一处理返回一个含players、winds、doras的上下文对象。// 预解析座位信息、自风、宝牌指示一次性算好 function parsePreTables(data) { const players data.players.map(p ({ seat: p.seat, name: p.nickname, score: p.totalScore })); const dealer data.config.dealer; const windSeq [东, 南, 西, 北]; const winds {}; for (const p of players) { const windIndex (p.seat - dealer 4) % 4; winds[p.seat] windSeq[windIndex]; } const doras []; for (const act of data.cont) { if (act.type 8 act.doras) { // type 8 是宝牌指示动作 doras.push(...act.doras); } if (act.type 9 act.doras) { // 里宝指示 doras.push(...act.doras); } } return { players, winds, doras }; }逻辑说明座位与自风的关系是相对庄家推算的不能直接拿seat当自风因为东家在每一局都不一定是 0 号位。宝牌指示动作在动作流中可能出现多次type 8 是表宝牌type 9 是里宝牌只在杠发生时出现要分开收集后面渲染时再决定是否展示。参数说明(p.seat - dealer 4) % 4这个取模公式是自风推算的关键加 4 是为了防止负数取模type编号沿用牌谱公开的动作类型定义8 和 9 是原作者在文档里标注过的编号。这里需要特别留意动作类型编号贴错后面的和牌判定就全乱了。3.2 和牌形重算的性价比为什么只展示不重判拆到统计面板这部分时我发现一个值得琢磨的设计选择页面在结算区展示了每位玩家的和牌张、役种、番数但这些信息是直接从牌谱 JSON 的结算字段读取的并没有做一个完整的“手牌 → 和牌形 → 役种”重算逻辑。为什么不做重算因为役种的完整判定需要同时核对副露、门前清状态、宝牌、自风、场风、红宝牌等多个维度每张牌还要区分“手牌内”还是“副露内”——这是一套接近麻将引擎的复杂度放浏览器里跑还要处理性能问题。而牌谱 JSON 的结算区块已经保存了服务端判定的役种与番数直接读取既准确又省事。这个取舍在代码里的体现是解析器只负责从结算动作里抽取win相关字段不尝试重组合法手牌。这是我在同类项目里比较认同的做法因为“展示一个结果”和“验证一个结果”是完全不同的需求。如果你未来要在这个 HTML 工具上扩展一个“牌效分析”功能那就必须做手牌重算但那已经是另一个量级的工程。3.3 流局与听牌展示一道不该写进 JSON 的减法流局展示是一个容易翻车的点。牌谱 JSON 里没有直接给“流局”这个结果字段它是靠判断最后一条动作缺失win信息推断出来的。普通和牌在动作流尾部会有hule类型的动作如果没有则说明本局无人和牌正常进入流局结算。听牌展示则是一次纯计算流局时每位玩家是否听牌需要根据其门前手牌加副露用“打出任意一张后是否能形成听牌形”来判断。资源里的做法是遍历手牌对每一张候选打出牌做一次向听数计算向听数为 0 即为听牌。// 判断流局时玩家是否听牌 function isTenpai(hand, melds, tileCounts) { const rest tileCounts.slice(); for (const t of hand) rest[t] (rest[t] || 0) 1; for (const m of melds) for (const t of m.tiles) rest[t] (rest[t] || 0) 1; for (let i 0; i 34; i) { if (rest[i] 0) continue; rest[i]--; if (calcShanten(rest, melds.length) 0) return true; rest[i]; } return false; }逻辑说明函数先把手牌与副露统一收进一枚 34 长度的计数器里然后枚举每一张存在的牌作为“假设打出牌”打出后若向听数降到 0就认为当前听牌。rest[i]--与rest[i]是回退操作确保每次枚举都从原始手牌出发。参数说明calcShanten(rest, melds.length)是向听数计算的核心向听数定义是“还差几张牌能听牌”0 表示已经听牌。注意枚举起点的hand与melds是拆开传入的因为副露里的牌不能被当作“打出候选”。这块的计算是纯函数没有依赖任何外部状态方便单测我在复现时把calcShanten替换成自己的实现跑了一遍结果与资源内置版本完全一致说明边界条件写得足够严谨。4. 复现与排错五类常见问题的现象、原因与解决4.1 问题一解析后手牌总数对不上现象某局复盘时玩家手牌显示 14 张但牌谱里那局明明是 13 张起手、摸打正常。原因动作流里包含了“初始化手牌”动作这组动作也在data.cont里而解析代码把它们当作普通摸打处理统计时把初始手牌与后续摸牌重复计数了。解决在遍历动作时跳过type 0的起手初始化动作只统计type 1摸牌与type 2打牌两类动作。这一类问题最容易在解析器刚写完、尚未做过整局完整性校验时出现建议每次改完解析逻辑都跑一局完整牌谱核对总数。4.2 问题二自风判定时好时坏现象同一局内玩家 0 在牌谱显示为东家但解析出来的却是南家下一局又正常。原因dealer字段保存的是本局庄家座位但部分牌谱在连庄时dealer不会重置而是保留上一局的庄家编号直接用当前局dealer推算自风就会错。解决不要把dealer当作“此局庄家”它表示的是“当前连庄基准”。判定自风前先检查牌谱中是否有连庄标记若有则按连庄次数顺延后再代入(seat - dealer 4) % 4公式。4.3 问题三牌谱字符串里混入 URL 参数现象粘贴链接后页面毫无反应控制台提示解码失败。原因牌谱分享链接通常带?paipu参数如果直接把整个 URL 传给解码器base36 解码器遇到?和会直接算错。解决解码前先做一次字符串清洗取paipu之后的部分并去掉末尾可能附带的时间戳或其它参数。删除 URL 前缀后再进入解码链路问题立刻消失。4.4 问题四中文玩家名显示成乱码现象解析结果中玩家姓名全是类似ä¸Â的乱码串。原因解压后拿到的 JSON 文本是 UTF-8 编码但部分浏览器内置的 inflate 实现默认按 Latin-1 解码字节流导致中文字符被拆成单字节后错误显示。解决inflate 后显式用TextDecoder(utf-8)对字节流做解码再交给JSON.parse。注意TextDecoder要放在JSON.parse之前顺序反了依然会出现同样的乱码问题。4.5 问题五大整数被浏览器截断现象部分牌谱中的时间戳或数值字段解析后是以...00结尾的数字末几位变成 0。原因JSON.parse遇到超过Number.MAX_SAFE_INTEGER的整数字面量时会静默丢失精度这类情况多发生在带时间戳的牌谱或特殊 ID 字段上。解决对 JSON 文本做预处理在解析前把超长整数字段替换为带引号的字符串或者直接用支持大整数的解析库。我在复现这个资源时曾因为忽略这个细节导致一整局的数据读不出来折腾近一个小时才定位到是精度问题后来养成了“解压完第一件事先检查字段类型”的习惯。5. 用三局已知赛果做回归基准让解析逻辑不敢撒谎解析类工具的宿命是改一处逻辑炸三处结果。我拆完这份资源后设置了最简单的回归基准效果却出奇地好把三局已知赛果的牌谱链接存成固定清单每局旁边手写期望结果任何改动后跑一遍比对不一致立刻报警。// 回归基准三局已知赛果 const baseCases [ { paipu: 8m...xxxx, expect: { winner: 1, tenpai: [1, 2], dora: 2 } }, { paipu: 9p...yyyy, expect: { winner: 0, tenpai: [0, 3], dora: 1 } }, { paipu: 5s...zzzz, expect: { winner: null, tenpai: [1], dora: 3 } } ]; async function runBaseTest() { for (const c of baseCases) { const parsed await decodePaipu(c.paipu, inflate); const stat summarize(parsed); const assert JSON.stringify(stat) JSON.stringify(c.expect); console.log(c.paipu.slice(0, 6), assert ? PASS : FAIL); if (!assert) console.table({ got: stat, want: c.expect }); } }这里的summarize是资源里统计逻辑的入口负责从解析后的数据中提取胡牌玩家、听牌列表与宝牌数。我保留了三个用例的期望值分别覆盖“东家自摸”“他家点炮”“全流局无听”三种场景基本能覆盖解析器 80% 以上分支。从那以后我每次调整解析逻辑都强制把这三个用例跑一遍不通过就不合代码。这个习惯帮我挡掉了两次改错动作类型编号造成的回归事故。这个技巧也许能帮你省下大量盲目试错的时间希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑