资讯详情

资讯详情

纯前端日麻牌谱分析器: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_idxr, turnturn, actorev.get(actor, -1), actionev[type], tileev.get(tile), targetev.get(target), is_tsumogiribool(ev.get(tsumogiri, False)), )) return rows with open(sample.json, encodingutf-8) as f: paipu json.load(f) rows flatten(paipu) with open(events.csv, w, encodingutf-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(fbad 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 Modulescript typemodule 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(maxsizeNone) 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应该是听牌向听 0123m 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(,)); } // 加 BOMExcel 直接双击打开不乱码 const blob new Blob([\ufeff lines.join(\n)], { type: text/csv;charsetutf-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 × 人数打点计算或供托处理有误顺位唯一每局四个或三个座位顺位不重复同点处理逻辑缺失第二项特别灵点数对不上基本就是打点算错了比逐个翻牌谱快得多。这套流程跑顺之后我形成了一个习惯每次改完统计口径先拿三份已知结果的牌谱跑回归三项自检全绿才允许自己看新数据。因为在没有基线的情况下看统计结果人很容易把口径错误解释成“我最近打法变了”。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →