ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯前端日麻牌谱分析器:JSON解析与和牌率统计

纯前端日麻牌谱分析器:JSON解析与和牌率统计

简介:MajsoulPaipuAnalyzer 是一款面向雀魂四人麻将玩家与牌谱复盘爱好者的跨平台分析工具,支持国服、日服和国际服,可在 Windows、Linux 与 macOS 上运行。它围绕牌谱统计与复盘场景,参考天凤牌谱解析程序实现多数分析项,并在结果网页中附上天凤凤凰桌牌谱的对照数据,方便玩家比较自身数据、定位打法问题;需注意修罗模式牌谱目前可能无法分析。 压缩包共七十四个文件、约一点一八兆字节,含十四个脚本、八个 C++ 源文件与九个头文件、七个配置文件和七个构建文件、两个页面、两个样式及十二个动图,另有文档、许可证与本地化资源,目录按源码、脚本、文档和素材分区。已有六千六百六十三人学习下载。读者可获得可编译运行的完整源码、跨平台构建配置和带对照数据的展示页面,并借此理解牌谱获取、解析与统计模块的组织方式,为二次开发、环境搭建和问题排查提供直接参考。

1. 一个 HTML 文件就能把日麻牌谱拆开看

打完一局总觉得某一巡打错了,靠记忆复盘基本等于自欺——人只会记住自己铳的那一手,记不住前面三巡已经把牌效打没了。日麻牌谱分析器的做法很朴素:把对局平台导出的牌谱 JSON 丢进一个纯前端 HTML 页面,浏览器本地解析,逐巡列出摸牌、打牌、鸣牌、向听变化和最终打点归属。没有后端、不用装环境、断网也能跑,双击打开就能用。适合两类人:想把自己每一手的决策量化下来的日麻玩家,以及想拿一份真实 JSON 练手解析、统计和渲染的前端或数据方向从业者。下面按数据结构、加载链路、指标口径、翻车点、进阶导出五块,把它拆到底。

2. 牌谱 JSON 的骨架:从事件流到可计算的表

拿到牌谱的第一反应往往是直接JSON.parse然后console.log,看到几百行嵌套对象就懵了。其实不管哪个平台导出的牌谱,语义上都跑不出三层:元信息、开局快照、动作序列。分析器做的事情,本质是把第三层那条线性的动作流,展开成一张“谁在第几巡做了什么”的宽表,后面所有统计都从这张表来。

2.1 三层结构:meta、init、events

元信息决定这局怎么算——规则是四麻还是三麻、平台版本、局数与座位;开局快照决定初始状态——四家起手配牌、宝牌指示牌、亲家、初始点数;动作序列决定过程——摸、打、吃碰杠、立直、和了、流局。中间层最容易出问题的是版本字段,因为不同客户端版本对同一语义的字段命名会变,所以业内常见做法是在解析入口做一层适配,把原始字段统一映射成语义化命名,后面的代码只认统一结构。

下面是一个归一化之后的骨架,字段名按语义写,实际字段以你手上的牌谱为准:

{ "meta": { "rule": "4p", "version": "0.11.x", "gameId": "sample-0001", "playerCount": 4 }, "init": { "rounds": [ { "round": 0, "dealer": 0, "honba": 0, "riichiSticks": 0, "doraIndicator": "5z" } ], "players": [ { "seat": 0, "startScore": 25000, "hand": ["1m","2m","3m","5m","0p","6p","7p","9s","1z","1z","3z","4z","4z"] } ] }, "events": [ { "round": 0, "type": "draw", "actor": 0, "tile": "7m" }, { "round": 0, "type": "discard", "actor": 0, "tile": "9s", "tsumogiri": true }, { "round": 0, "type": "draw", "actor": 1, "tile": "2z" }, { "round": 0, "type": "chi", "actor": 2, "target": 1, "tiles": ["4p","5p"] }, { "round": 0, "type": "riichi", "actor": 3 }, { "round": 0, "type": "hora", "actor": 0, "target": 1, "tile": "7m", "han": 3, "fu": 40 } ] }

这里有几个字段值得单独说。tsumogiri表示摸切,它直接决定对手能不能读出你的手牌变化,是做读牌统计时权重很高的一个信号。target只在鸣牌和荣和时出现,表示这张牌是从谁那里来的,写统计脚本时如果忽略它,放铳归属就会全错。han和fu是可选的,有些牌谱只给最终点数变化,打点需要自己反推。

2.2 用 Python 把事件流展开成表

真正做统计之前,我一般先用 Python 把牌谱跑一遍,落成 CSV 或者 Parquet,再上浏览器做可视化。原因很简单:Python 侧改口径快,浏览器侧改一次要刷新一次。下面这段是展开脚本的核心:

import json from dataclasses import dataclass, asdict @dataclass class Row: round_idx: int # 第几局,0 起 turn: int # 第几巡,按庄家摸牌递增 actor: int # 座位 0-3 action: str # draw/discard/chi/pon/kan/riichi/hora/ryukyoku tile: str | None # 涉及的牌,没有则为 None target: int | None # 鸣牌或荣和的放牌者 is_tsumogiri: bool # 是否摸切 def flatten(paipu: dict) -> list[Row]: rows: list[Row] = [] turn_counter: dict[int, int] = {} for ev in paipu["events"]: r = ev["round"] # 巡目只在"摸牌"时递增,鸣牌不推进巡目 if ev["type"] == "draw": turn_counter[r] = turn_counter.get(r, 0) + 1 turn = turn_counter.get(r, 0) rows.append(Row( round_idx=r, turn=turn, actor=ev.get("actor", -1), action=ev["type"], tile=ev.get("tile"), target=ev.get("target"), is_tsumogiri=bool(ev.get("tsumogiri", False)), )) return rows with open("sample.json", encoding="utf-8") as f: paipu = json.load(f) rows = flatten(paipu) with open("events.csv", "w", encoding="utf-8", newline="") as f: import csv writer = csv.DictWriter(f, fieldnames=[k for k in asdict(rows[0]).keys()]) writer.writeheader() writer.writerows(asdict(r) for r in rows)

逻辑上有两个点必须说清楚。第一,巡目只在draw事件里递增,因为吃碰杠不消耗巡目,如果你按事件条数当巡目,鸣牌多的局会被算成“打了更多巡”,和了率会被系统性压低。第二,turn_counter用字典按局号隔离,是因为一局牌谱里有多局,不隔离的话第二局的巡目会接着第一局往上加。

参数方面,round_idx建议保留原始局号而不是自己重排,后面按局聚合时不会错位;target在非鸣牌事件里给None而不是-1,是为了让 pandas 分组时能直接dropna。跑完这一步,你会得到一张几万行的表,任何口径的统计都是在它上面做groupby。

2.3 牌面编码:0 不是零,是红宝牌

这是新手最容易翻车的地方。日麻牌谱里0m、0p、0s表示红五,不是数字零,也不同于普通的5m。如果你的解析器把0m当成非法值丢掉,或者直接转成整数0,后面算打点会少一番,算有效牌会把红五当成不存在的牌。

常见的处理办法是给每张牌一个稳定的整数 id:万子 1-9 映射到 1-9,筒子 11-19,索子 21-29,字牌 31-37,红五单独映射到 41、42、43。这样排序、去重、做位运算都方便,红五也能在需要时还原。

SUIT_BASE = {"m": 0, "p": 10, "s": 20, "z": 30} HONOR_MAP = {"東": 1, "南": 2, "西": 3, "北": 4, "白": 5, "發": 6, "中": 7} def tile_to_id(tile: str) -> int: # 红五:0m / 0p / 0s,映射到独立区段,避免和普通 5 混淆 if tile[0] == "0": return 40 + {"m": 1, "p": 2, "s": 3}[tile[1]] num, suit = tile[0], tile[1] if suit == "z": return 30 + HONOR_MAP[tile[0]] return SUIT_BASE[suit] + int(num) def id_to_tile(tid: int) -> str: if tid > 40: return "0" + {1: "m", 2: "p", 3: "s"}[tid - 40] if tid > 30: inv = {v: k for k, v in HONOR_MAP.items()} return inv[tid - 30] + "z" for suit, base in SUIT_BASE.items(): if suit != "z" and base < tid <= base + 9: return f"{tid - base}{suit}" raise ValueError(f"bad tile id: {tid}")

tile_to_id只做映射不做校验,非法输入会抛KeyError,这在批量导入时反而是好事——能立刻定位到是哪一局哪一条牌谱字段坏了,而不是悄悄产出一份错数据。id_to_tile是给渲染层用的,表格里显示的还是人眼熟悉的5m、東,内部计算一律走整数。

3. HTML 单页怎么跑起来:本地加载与解析链路

这类分析器最大的优势就是部署成本为零。你不需要 Node、不需要数据库、不需要登录,一个index.html加上几个 js 文件,双击或者用任意静态服务器起一下就能用。但纯前端也意味着所有计算都在用户机器上,牌谱一大就容易假死,所以链路设计比功能本身更值得讲。

3.1 文件组织与依赖边界

我一般把目录拆成四块:入口index.html、解析层parser.js、统计层stats.js、渲染层view.js。解析层不碰 DOM,统计层不碰解析细节,渲染层只吃统计层输出的结构。这条边界划清楚之后,你才能把解析和统计搬进 Web Worker,而不用重写业务代码。

依赖上尽量不引入构建工具。少一层打包,就少一层“换了机器跑不起来”的可能。图表用轻量的 canvas 库或者干脆手写 SVG,表格自己做分页。唯一值得引的外部依赖是虚拟滚动,如果牌谱超过一局五万条事件,全量 DOM 渲染必然卡。

如果一定要用模块化,用原生 ES Module,<script type="module" src="./main.js">,浏览器直接认,不需要 bundler。代价是本地file://协议下模块加载会被同源策略拦,需要起个静态服务:

python -m http.server 8080 # 然后浏览器打开 http://localhost:8080/index.html

这条命令比装任何脚手架都快,别嫌土。

3.2 File API 读入与解析主循环

用户拖一个文件进来,用File.text()读成字符串,再JSON.parse。看似两行,实际有两个坑:一是文件编码,二是JSON.parse的同步阻塞。

async function loadPaipu(file) { // 先按 UTF-8 读,失败再退回 GBK 兼容分支 const buf = await file.arrayBuffer(); let text; try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch (e) { text = new TextDecoder('gbk').decode(buf); } let raw; try { raw = JSON.parse(text); } catch (e) { throw new Error(`牌谱 JSON 解析失败:${e.message},文件前 80 字符:${text.slice(0, 80)}`); } // 交给 Worker 做重型计算,主线程只负责 UI 状态 return analyzeInWorker(raw); }

fatal: true这个参数是关键,它让解码器在遇到非法字节时抛错而不是静默替换成乱码字符。不加这个参数,一份 GBK 编码的牌谱会被 UTF-8 解成一半乱码,JSON.parse报的错会指向一个完全无关的位置,排查能耗掉半小时。错误信息里带上文件前 80 个字符,是我踩过坑之后加的后悔药——一眼就能看出是编码问题还是文件根本不是牌谱。

analyzeInWorker的接法:

function analyzeInWorker(raw) { return new Promise((resolve, reject) => { const worker = new Worker('./analyze.worker.js', { type: 'module' }); worker.onmessage = (e) => { if (e.data.ok) { resolve(e.data.result); worker.terminate(); } else { reject(new Error(e.data.error)); worker.terminate(); } }; worker.onerror = (e) => { reject(e); worker.terminate(); }; worker.postMessage(raw); }); }

用type: 'module'是为了让 Worker 里也能import解析层,不用把代码复制一份。worker.terminate()放在每个分支里,是因为一次会话可能加载多份牌谱,不回收会累积线程。

3.3 Worker 里的分片与进度回报

Worker 里做的事情按顺序是:归一化字段、展开事件、逐巡算向听、按局聚合。牌谱小的时候几十毫秒完事,大到上千局批量导入时,用户会盯着空白页面怀疑卡死。所以每处理完一局就postMessage一次进度,主线程更新进度条。

// analyze.worker.js import { normalize, flattenRounds, calcShantenSeries } from './stats.js'; self.onmessage = (e) => { try { const rounds = flattenRounds(normalize(e.data)); const result = []; for (let i = 0; i < rounds.length; i++) { result.push(calcShantenSeries(rounds[i])); // 每局回报一次,主线程不阻塞 self.postMessage({ progress: (i + 1) / rounds.length }); } self.postMessage({ ok: true, result }); } catch (err) { self.postMessage({ ok: false, error: err.message }); } };

进度回报的粒度要按局而不是按事件,因为一局内的事件数差异很大,按事件回报会导致进度条跳变,体验反而更差。这是一个渲染细节,但它决定用户愿不愿意等你的分析跑完。

渲染层拿到结果之后,表格只渲染当前页,其余进内存缓存。牌谱分析的表格通常有十几列、上万行,一次性innerHTML拼接会让浏览器直接卡住三秒以上。分页加虚拟滚动是必做项,不是优化项。

4. 指标口径:和牌率、放铳率到底怎么算

统计工具最容易出的问题不是算法写错,而是口径没定死。同一份牌谱,换个分母,和了率能差出五个百分点。所以在写任何一行统计代码之前,我一般会先在一张纸上把这几个指标的分母写清楚。

4.1 先把分母钉死

和了率的分母是什么?是“局数”还是“自己摸牌的巡数”?两者差别巨大。用局数做分母,运算简单,但会掩盖“早早鸣牌速攻”和“长期门清慢打”的差异;用自摸巡数做分母,能反映效率,但需要处理流局时没有结束巡目的情况。我的做法是两个都算,主指标用局数,辅助指标用巡数,页面上并排显示,避免单一数字误导。

放铳率同理。分母用“被和了的局数”是对的,但要注意别把自摸算进去——自摸没有放铳者。如果解析时把自摸事件的target写成自己,放铳率会凭空多出一截。下面这张表是我一般会写进统计脚本的口径说明:

指标分子分母常见错法
和了率自己为和牌者的局数总局数用“有和牌发生的局数”
放铳率自己为target的荣和局数被和了的局数把自摸的target算成自己
自摸率自己自摸和了的局数自己和了的局数分母用了总局数
立直率自己宣言立直的局数自己门清可立直的局数分母用了总局数
副露率自己吃碰杠的局数总局数把暗杠也算成副露
平均顺位各局顺位之和局数三麻四麻混在一起算

最后一行是三麻玩家最容易被坑的地方,三麻只有三个顺位,平均顺位的基准是 2.0 而不是 2.5,混着算出来的数字没有任何意义。

4.2 向听数与有效牌的计算

向听是这套工具里计算量最大的部分。标准型向听数常用的是递归拆解:先枚举雀头,再把剩余牌尽量拆成面子,剩下的搭子数决定向听。七对子和国士无双单独判断,取三者最小值。

from functools import lru_cache @lru_cache(maxsize=None) def shanten_standard(counts: tuple[int, ...], melds: int = 0) -> int: """ counts: 长度 34 的元组,每种牌的张数(红五已并入普通 5) melds: 已副露的面子数,每副露一个少一组需要凑的面子 返回标准型向听数,-1 表示和牌 """ best = 8 # 枚举雀头 for i in range(34): if counts[i] >= 2: c = list(counts); c[i] -= 2 best = min(best, _mentsu(c, 0) - 1) # 有雀头,向听减一 # 无雀头的情况 best = min(best, _mentsu(list(counts), 0)) return best def _mentsu(c: list[int], pairs: int) -> int: # 递归取面子与搭子,返回 8 - 2*面子 - 搭子 - 雀头 形式的向听基准 for i in range(34): if c[i] >= 3: c[i] -= 3 r = _mentsu(c, pairs) c[i] += 3 return r # 顺子处理略,实际实现按花色分段 return 8 - pairs

上面这段是结构示意,真实实现里顺子要按花色分段枚举,_mentsu的返回值也要按“面子数 × 2 + 搭子数”折算。写这段代码时我建议先用手牌对照已知答案跑单元测试,比如123m 456p 789s 11z 23m应该是听牌(向听 0),123m 456p 789s 1z 2z是向听 1。测试不过就别往下算有效牌,因为有效牌是基于向听变化枚举出来的,向听错一位,后面全错。

有效牌的计算是:枚举 34 种牌,每种加一张,看向听数是否下降,下降的牌就是有效牌,张数按剩余枚数累加(4 减去自己手里和场上可见的)。这个循环在浏览器里对每一巡都跑一遍,开销不小,所以常见做法是缓存——同一手牌只算一次。

4.3 押し引き阈值:别用感觉代替数字

有了指标之后,最实际的用法是给自己定一个押引阈值。比如“巡目 ≥ 12 且自己向听 ≥ 2 且对手已立直”时,默认拆听弃和。这类规则没有绝对正确,但写进工具里之后,你可以回看自己有多少次违反了它,以及违反之后的结果分布。这比单纯看和了率有用得多。

我一般会给三档:进攻、中立、防守。判定输入是巡目、自己向听、对手立直数、点差、剩余牌山。输出是一个标签,附在每一巡的记录上。跑完一批牌谱之后,按标签分组看放铳率,就能发现自己到底是在哪种局面下亏分。这套阈值表不需要一开始就调准,先跑起来,再看数据回调,比纸上推演快得多。

5. 避坑与排查:五个真实翻车点

5.1 三麻四麻混算,平均顺位从 2.5 变成 2.2

现象:批量导入之后,平均顺位显示 2.2,但自己体感是中等偏上。

原因:三麻只有三个顺位,均值基准是 2.0,四麻是 2.5,混在一起算出来的数字既不是三麻的也不是四麻的。

解决:解析入口读meta.rule或playerCount,按规则分组统计,页面上做切换而不是求平均。如果牌谱里没有这个字段,用参与人数从init.players的长度反推,反推不出来就直接拒绝导入并提示,别猜。

5.2 流局没进分母,和了率虚高

现象:和了率算出来 28%,明显高于自己的实际感受。

原因:分母用的是“有和了事件的局数”,流局局根本没进统计,等于把所有没和成的局都剃掉了。

解决:分母改成“局开始事件”的数量,也就是每局第一条draw之前的初始化事件。九种九牌、四风连打这类途中流局也要计入,它们在牌谱里是一条独立事件,不是“没有事件”。

5.3 版本字段变动,undefined一路传到底

现象:换个客户端版本导出的牌谱,页面上某一列全是NaN。

原因:新版本把某个字段从顶层挪进了子对象,或者改了命名,解析层没做兼容,undefined参与了算术运算。

解决:在归一化函数里对每个必需字段做存在性检查,缺失就抛带字段名和局号的错误,而不是给默认值。默认值是万恶之源——它会让你以为数据是对的。另外维护一张版本兼容表,遇到未知版本先走宽松解析,把告警打到控制台。

5.4 红宝牌当普通五,打点少一番

现象:某几局的和牌打点比实际低,尤其是手里有红五的时候。

原因:0m被当成非法字符丢掉,或者被转成整数 0 参与牌型判断。

解决:按 2.3 节的编码方案,红五映射到独立 id,在算番时还原成“红五”标志位。测试用例里必须包含带红五的手牌,且断言打点与牌谱给出的最终点数一致。

5.5 大牌谱卡死主线程,页面白屏三秒

现象:导入一份上千局的牌谱,浏览器标签页无响应,转圈圈。

原因:JSON.parse本身还好,真正的瓶颈是全量 DOM 渲染和逐巡向听计算都在主线程上跑。

解决:解析和统计进 Worker,渲染分页加虚拟滚动,进度按局回报。如果单份文件超过几十兆,考虑先做一次轻量预扫描,只算局数和时间范围,让用户确认之后再跑全量分析。

6. 进阶:把分析结果导成可对比的 CSV

单局分析看的是“这一手对不对”,多局对比看的是“我的习惯有没有问题”。所以我最后一定会加一个导出功能,把每局的汇总指标落成 CSV,一年下来几十份文件丢进同一个目录,用脚本做趋势对比。

function exportCsv(roundSummaries) { const header = ['gameId', 'round', 'seat', 'winRate', 'dealInRate', 'riichiRate', 'avgShanten', 'avgUkeire', 'result']; const lines = [header.join(',')]; for (const r of roundSummaries) { // 数值统一保留四位,避免不同浏览器浮点字符串化差异 const row = [ r.gameId, r.round, r.seat, r.winRate.toFixed(4), r.dealInRate.toFixed(4), r.riichiRate.toFixed(4), r.avgShanten.toFixed(2), r.avgUkeire.toFixed(1), r.result ]; lines.push(row.join(',')); } // 加 BOM,Excel 直接双击打开不乱码 const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `paipu_summary_${Date.now()}.csv`; a.click(); URL.revokeObjectURL(url); }

两个参数值得说明。toFixed(4)是为了让不同机器导出的文件能直接diff,浮点数默认字符串化在不同引擎上位数不一致,做批量对比时会冒出大量假差异。\ufeff这个 BOM 不是可选装饰,没有它,部分表格软件打开中文列名就是乱码。

导出之后我一般会跑一遍自检,确认三件事对得上:

自检项检查方法不一致说明什么
局数守恒各结果类型计数之和 = 总导入局数有事件被漏解析或重复解析
点数守恒每局终局点数之和 = 25000 × 人数打点计算或供托处理有误
顺位唯一每局四个(或三个)座位顺位不重复同点处理逻辑缺失

第二项特别灵,点数对不上基本就是打点算错了,比逐个翻牌谱快得多。

这套流程跑顺之后,我形成了一个习惯:每次改完统计口径,先拿三份已知结果的牌谱跑回归,三项自检全绿才允许自己看新数据。因为在没有基线的情况下看统计结果,人很容易把口径错误解释成“我最近打法变了”。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进