简介:这是一款面向雀魂玩家与牌谱分析爱好者的开源工具,支持国服、日服、国际服,并提供 Windows、Linux、macOS 三个平台的版本。工具以四人麻将牌谱为分析对象,参考天凤牌谱解析程序的实现思路,已覆盖除被鸣牌和门清听牌大类外的多数数据维度,结果页还附带天凤凤凰桌的参考数据,便于用户对照自身对局表现。资源压缩包共 74 个文件,约 1.18MB,包含 js、cpp、h、json、html、css 等类型:js 与 html/css 用于构建前端展示与页面,cpp/h 实现核心分析逻辑,json/yml 负责配置与多语言支持,结构清晰,便于二次开发与学习。目前已吸引 6659 人学习下载,适合想深入理解雀魂牌谱结构、参考实现思路或自行扩展分析功能的开发者与进阶玩家。
1. 雀魂牌谱分析工具:为什么牌谱比战绩页更值得挖
打雀魂的人早晚会走到这一步:段位卡住,战绩页上的三位率、四位率来回晃,却说不清问题出在哪。有人归咎于运气,有人去调打法,但真正能给出答案的,是每一局留下的完整牌谱——不是赛后回放那个只能看的动画,而是记录了每一巡摸牌、切牌、副露、立直判断的原始事件流。MajsoulPaipuAnalyzer 就是一个把牌谱从JSON还原成可统计、可复盘、可复盘指标的本地分析工具。它的核心价值不是替代人眼复盘,而是把整场对局的行为拆成可量化动作:哪些牌该吃没吃、哪手牌立直成本高于收益、哪几次副露把牌型锁死,这些靠人脑记不住,但靠解析脚本能逐局算出。
这个方向适合两类人:一类是正在冲分、想用数据修正打法的玩家,另一类是给自己找练习项目的工程师——牌谱解析里文本处理、状态机建模、SQL查询全都能落地。本文会把采集、解析、指标计算、排错一路写下来,照着做能跑出一份属于你自己的对局行为报告。
2. 牌谱从哪来:日志目录、浏览器扩展与JSON样本结构
2.1 牌谱文件在哪:本地日志与网页端导出的差别
雀魂的牌谱不会主动给你一份干净的JSON文件。最常见的获取方式是打开一局对局的结果页,URL里带一段牌谱ID,通过牌谱ID可以对局详情接口拉取完整记录;另一种方式是把游戏数据目录里的日志翻出来,日志里缓存了最近对局的牌谱原始数据。前者适合做历史对局回溯,后者适合做实时采集,两者并不互斥。
我一般会先确认本地方案能不能跑通。雀魂的本地日志通常落在用户数据目录下,按日期滚动,内容包含牌谱ID和一部分元数据。好处是无需额外请求、完全不依赖网页端登录态,坏处是日志格式随客户端版本变动,字段对齐需要你对照实际内容微调。网页端导出的好处是直接拿到结构清晰的JSON,代价是需要手动复制牌谱ID或者用浏览器扩展辅助抓取。
# 以类Unix系统为例,先按时间排序找到最近更新的日志文件 find ~/.local/share -type f -name "*.log" -mtime -1 | sort这条命令的作用是定位当天有写入的日志文件,参数上-mtime -1表示只找一天内修改过的文件,~/.local/share是常见用户数据目录,实际路径因系统和客户端安装方式而异。如果找不到,可以换成检索整个用户目录下的*.log再人工排查,不要一上来就猜路径。
2.2 牌谱JSON长什么样:先看元数据再看事件流
无论走本地还是网页端,最终拿到手的牌谱JSON都可以粗略分成两层:外层是元数据——对局规则、玩家列表、初始座位、最终分数和顺位;内层是核心事件流——每一巡的摸牌、切牌、副露、立直、和牌、流局等动作。分析工具真正要解析的是事件流,但元数据决定了事件如何解读。
以常见的简化结构为例,每个对局对象里会有一个actions数组,数组中每个元素代表一个动作,动作对象通常带type、actor、tile之类的字段。type表示动作类型,actor表示玩家座位号,tile是牌的编码。不同客户端版本字段名可能不同,但你只要抓住「时间推进 + 行为变更」这条主线,就能把事件流还原成牌桌状态。
{ "meta": { "mode": 2, "players": ["playerA", "playerB"], "final_score": [35000, 27000] }, "actions": [ { "type": "draw", "actor": 0, "tile": "1m" }, { "type": "discard", "actor": 0, "tile": "1m" } ] }逻辑说明:meta.mode表示规则类别,players数组按座位顺序记录用户名,actions按时间顺序记录每一手操作。参数说明:actor是从0开始的座位号,tile的命名规则一般遵循「数字+花色」的编码方式,1m是万子1、1p是饼子1、1s是索子1,0m常用来替代红宝牌。解析的第一件事永远是把这只meta和actions的关系理清,否则后面算出来的全是错位数据。
2.3 最小采集方案:从牌谱ID到本地JSON的拉取脚本
有了牌谱ID,剩下的工作就是把接口返回的JSON存到本地。写脚本时不要只存最终结果,原始JSON一定要保留,因为你后续所有特征提取都建立在原始数据上,一旦字段理解错误还能回查。
import json import urllib.request REPLAY_ID = "your_replay_id_here" API_BASE = "https://example-api.invalid/replay" def fetch_paipu(replay_id: str) -> dict: url = f"{API_BASE}/{replay_id}" with urllib.request.urlopen(url, timeout=30) as resp: return json.load(resp) if __name__ == "__main__": paipu = fetch_paipu(REPLAY_ID) with open(f"{REPLAY_ID}.json", "w", encoding="utf-8") as f: json.dump(paipu, f, ensure_ascii=False, indent=2) print(f"fetched {len(paipu.get('actions', []))} actions")逻辑说明:fetch_paipu负责请求和解析,json.dump时开启ensure_ascii=False是避免用户名或备注里的中文被转成\uXXXX,不方便后续定位。参数说明:timeout=30是防御性写法,牌谱接口偶发慢响应,不给超时会让脚本挂死。这里接口地址是示意,实际使用时以你抓包看到的域名和路径为准。
3. 解析牌谱JSON:从原始事件流还原每一巡手牌与切牌
3.1 为什么要自己写解析器:回放动画给不了统计口径
很多人第一反应是「雀魂自带回放,我看一遍不就行了」。问题是回放动画的信息密度太低:一场对局下来几十巡、上百个动作,人眼看完只能记住几个关键点,想要统计「我平均第几巡立直」「先制立直胜率多少」根本无从下手。自己写解析器,本质上是把「视觉回放」转成「结构化事件表」,让统计口径可以复算。
解析器的核心动作是状态复原。你手里的输入是动作流,输出应该是每一巡结束时的牌桌状态:每家手牌、河牌、副露、宝牌指示、立直状态。状态复原的难点在于事件之间有依赖,比如「吃」这个动作会同时改变上家河牌、当前玩家手牌、副露区,任何一处没同步,后面的判断都会翻车。
3.2 手牌状态机的三个关键动作:摸牌、切牌、副露
手牌状态机的设计不需要很复杂,三个核心方法就够了:apply_draw处理摸牌,apply_discard处理切牌,apply_meld处理副露。每个方法都做同一件事——把事件作用到当前状态上,并返回新状态。设计上保持纯函数,便于单元测试。
class PlayerState: def __init__(self, seat: int): self.seat = seat self.hand = [] self.discards = [] self.melds = [] self.is_riichi = False def apply_draw(self, tile: str): self.hand.append(tile) def apply_discard(self, tile: str): if tile not in self.hand: raise ValueError(f"tile {tile} not in hand: {self.hand}") self.hand.remove(tile) self.discards.append(tile) def apply_meld(self, tiles: list[str], consumed_tile: str): for t in tiles: if t in self.hand: self.hand.remove(t) if consumed_tile in self.discards: self.discards.remove(consumed_tile) self.melds.append(tiles)逻辑说明:apply_discard里先做存在性校验,这是为了在解析阶段暴露出牌谱事件与手牌不同步的问题,而不是让错误悄悄累积。apply_meld里同时处理了手牌减少和河牌回撤,因为副露会拿走别家打出的牌,这张牌在逻辑上不属于任何人的手牌,但要从河牌里移除。参数说明:tiles包含被吃/碰/杠的整组牌,consumed_tile是别家打出的那一张,两者缺一不可。
3.3 手切与摸切的判定:一个让数据分析翻车的细节
日麻分析里手切和摸切是重要特征——手切是玩家从手中主动选牌打出,摸切是摸到什么打什么。判定方法在回放里很简单,看切牌的来源即可,但在牌谱JSON里这个信息不是每次都有。很多事件流只记录「谁打了什么牌」,不直接告诉你这张牌是刚摸到的还是原本手里的。
常见做法是维护每个玩家的「待切牌」缓冲:摸牌动作记录刚摸到的牌,切牌动作发生时,如果切出的牌等于缓冲区的最后一张,且没有副露、立直等干扰事件,就判定为摸切,否则判定为手切。这个方法在大部分场景下可靠,但遇到「摸牌后鸣牌再切牌」的复合动作时容易误判。
def classify_discard(events, idx): event = events[idx] if event["type"] != "discard": return None prev = events[idx - 1] if prev["type"] == "draw" and prev["tile"] == event["tile"]: return "tsumogiri" return "teogiri"逻辑说明:这个简化版本只比较前一个事件是不是同一玩家的摸牌,且牌面一致,是则视为摸切。参数说明:idx是事件索引,使用前一事件作为上下文,不做跨事件追踪。实际项目中建议把PlayerState扩展一个last_drawn_tile字段,在apply_draw时记录,在apply_discard时比对,比回溯事件数组更可靠,也更好调试。
3.4 把解析结果落成表:每巡行动与全局特征分开存档
解析完一整局后,建议输出两类内容:一类是逐巡明细,另一类是整局统计。逐巡明细方便你对着具体某手牌排查;整局统计是后续建模和复盘的基础。明细表无需过度设计,字段够用就行。
import csv from pathlib import Path def export_turn_table(records, output_dir: str): fieldnames = ["seat", "turn", "action_type", "tile", "hand_after", "is_tsumogiri"] output = Path(output_dir) / "turns.csv" with open(output, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(records)逻辑说明:hand_after字段用于记录动作完成后的手牌快照,这会让文件体积变大,但排查逻辑错误时非常有用,一条记录就能看出状态机在哪一步出了问题。参数说明:newline=""是Python写CSV的标准写法,避免在Windows平台出现空行;is_tsumogiri就是从上一节判定逻辑里拿到的布尔结果,作为布尔值写入会被转成True/False,后续读取时注意类型转换。
4. 特征计算与复盘指标:向听数、副露效率与立直收益
4.1 向听数计算:先手牌做面子拆解再数未完成面子
向听数是日麻分析里最基础的指标,意思是达到听牌还差几次有效进张。计算思路不复杂:把14张手牌拆成面子、雀头、搭子的组合,数出距离和牌还缺几个组件。难点在于枚举所有拆法取最优,手牌规模不大时用递归足够,性能不是问题。
from functools import lru_cache @lru_cache(maxsize=None) def min_shanten_tiles(hand_tuple): hand = list(hand_tuple) if len(hand) == 0: return 0 best = 8 for i in range(len(hand)): rest = hand[:i] + hand[i+1:] best = min(best, 1 + min_shanten_tiles(tuple(rest))) return best逻辑说明:这是一个「删牌到空」的简化模型,每删一张牌记一次代价,最终递归到空手牌时返回0。实际向听数不是这么算的,它要考虑面子拆解和进张,但这段代码展示了递归枚举的基本框架——把抽象问题变成可计算的子问题。参数说明:lru_cache用于缓存相同手牌组合的计算结果,同一手牌在整局中会被反复评估,缓存能省掉大量重复计算。
真要严格计算向听数,推荐的做法是枚举所有可能的「完成面子」组合,剩下的牌找雀头和搭子,最后取最小向听数。网上有现成算法参考,但建议自己实现一遍,因为只有自己实现过,才知道边界情况——比如七对子手牌向听数可能和普通型不同,需要单独计算后取小值。
4.2 副露效率:从「第几巡副露」到「副露后向听数变化」
副露是日麻里最容易上头的动作。分析副露价值时,我通常会统计两个数字:副露发生的巡目、副露前后向听数的变化量。如果一次副露让向听数不变甚至倒退,那这次副露的收益就只能靠速度来补偿,属于高风险选择。
def analyze_meld(hand_before, hand_after, meld_type): shanten_before = min_shanten_tiles(tuple(hand_before)) shanten_after = min_shanten_tiles(tuple(hand_after)) delta = shanten_before - shanten_after return { "meld_type": meld_type, "shanten_before": shanten_before, "shanten_after": shanten_after, "delta": delta, }逻辑说明:delta为正值表示向听数减少,副露有推进作用;为负值表示副露后牌型反而退化了。参数说明:meld_type区分吃、碰、杠、加杠,因为不同副露对牌型限制不同,碰会破坏手牌灵活性,吃会受上家限制,分析时要分开统计。hand_before和hand_after来自状态机快照,而不是从牌谱里重新数,这样保证口径一致。
副露效率的统计口径建议按「巡目区间」分组,比如1-4巡的早巡副露、5-8巡的中巡副露、9巡以后的晚巡副露。不同区间的副露价值判断完全不同,混在一起算平均会掩盖问题。早巡副露通常是为了抢速度,晚巡副露往往是防守或愚形处理,数值含义不一样。
4.3 立直收益的三种评估方式:火力、速度与和牌率
立直是贯穿全局的大决策,单看「立直后和没和」太片面。我会从三个维度评估一次立直:立直巡目高不高(速度)、手牌里宝牌和DORA相关牌占比(火力)、有没有现物安全牌(防守)。这三个维度各自打分再合成一个收益指数。
def riichi_score(turn, dora_count, safe_tiles_count): speed_score = max(0, 12 - turn) power_score = min(10, dora_count * 3) safety_score = min(8, safe_tiles_count * 2) return speed_score + power_score + safety_score逻辑说明:speed_score把巡目越早得分越高,12减去巡目能保证早巡有较高基础分;power_score按宝牌数线性放大,3倍系数是经验值;safety_score用现物安全牌数量衡量防守余裕。三个分数加总后,可以作为立直与否的参考阈值。参数说明:dora_count是手牌中宝牌及赤宝牌合计张数,safe_tiles_count是河牌中已经出现的、打出去肯定安全的牌种数。
实际使用时阈值不要定太死。早巡立直即便火力不足也常常值得做,因为它能压迫对手,晚巡立直则必须有火力或防守支撑。指标是辅助你复盘的,不是替你决策的。算出自己每局的平均立直得分后,再回头对照胜率,能看出你在哪个维度上拖了后腿。
4.4 将全量牌谱跑一遍:批量解析与结果落库
单局解析只是热身,真正有价值的分析要跑完几十上百局。批量处理时,最关键的是容错:某局牌谱字段缺失、解析抛错、存入数据库失败,都不能让整个任务停下来。常见做法是逐局try/except,把失败的牌谱ID记下来最后统一排查。
import sqlite3 from concurrent.futures import ProcessPoolExecutor def process_one(replay_id: str, db_path: str): try: paipu = fetch_paipu(replay_id) stats = analyze_paipu(paipu) save_to_db(stats, db_path) except Exception as exc: print(f"[fail] {replay_id}: {exc}") def run_batch(replay_ids, db_path, workers=4): with ProcessPoolExecutor(max_workers=workers) as pool: pool.map(lambda rid: process_one(rid, db_path), replay_ids)逻辑说明:ProcessPoolExecutor用多进程并行拉取与解析,因为网络请求和JSON解析都是CPU/IO混合型任务,多进程比多线程更容易跑到理想吞吐。参数说明:workers=4是保守值,多数机器上不会触发限流;db_path作为参数传入而不是全局变量,是为了配合多进程环境。save_to_db内部建议用事务批量写入,逐条提交会拖慢整体速度。
5. 避坑指南:牌谱分析常见的五个翻车点
5.1 文件编码与路径问题导致的「解不出来」
现象:脚本在部分牌谱上反复报UnicodeDecodeError或者json.JSONDecodeError,同一批数据有的能读有的不能读。
原因:不同来源的牌谱文件编码不统一。网页端导出通常是UTF-8,本地日志可能是带BOM的UTF-8,甚至偶尔出现GBK编码的旧日志。你用open()默认或者固定UTF-8去读,遇到其他编码就会炸。
解决:读取文件时先探测编码再解码,最简单的方式是用utf-8-sig容错,或者不指定编码、改用二进制读取后交给json解析库处理。我一般会优先尝试UTF-8,失败后回退到GBK,再失败就记录文件路径跳过。
5.2 手切与摸切误判:原因是事件流里「摸牌」不是紧挨着「切牌」
现象:统计出来的摸切率接近百分之百,明显不符合实际打法。
原因:很多牌谱里摸牌动作和切牌动作之间有其他事件插入,比如对手的副露、宝牌指示翻转、立直宣言。前面那种「比较上一条事件」的简化判定在这里直接失效,把大量手切误判成摸切。
解决:不要依赖相邻事件,在PlayerState上维护last_drawn_tile字段,遇到摸牌就更新,遇到切牌就与字段比对,比对后立即清空字段。这样即使中间插入了其他事件,判定依然正确。
5.3 同一桌牌重复统计:原因是牌谱ID可能重复出现在多个数据源
现象:总对局数远超实际打的局数,胜率指标虚高。
原因:本地日志和网页端导出覆盖了同一批对局,批量处理时没有去重,或者同一局通过不同方式被拉了两次。
解决:用牌谱ID做主键,写入数据库时先查重,冲突则跳过。如果确认不同来源的ID规则一致,可以合并处理;如果不一致,最好统一以其中一个来源为准做映射。
5.4 副露后手牌数量不一致:原因是状态机没同步「打出的那张牌」
现象:解析到某次碰牌后,手牌数量莫名多了一张或少了一张,后续所有判断都开始错乱。
原因:副露动作发生时,需要从手牌中移除被吃碰的牌,同时从河牌中移除被消费的那张牌。很多初学者只处理了手牌移除,忘了河牌回撤,导致状态错位。
解决:在设计apply_meld时,把「手牌移除、河牌移除、副露列表追加」写成一步原子操作,并且在处理完后打印手牌数量做断言。批量跑数据时开启断言,一旦发现数量不符立刻停止该局解析并报错,不要带着错误状态继续算指标。
5.5 不同版本牌谱字段名不兼容:原因是客户端更新改了数据结构
现象:昨天还能解析的牌谱,今天全部解析失败,报KeyError: 'tile'。
原因:雀魂客户端更新后,动作事件里的字段名发生变化,或者新增了之前没见过的动作类型。
解决:解析层加一层字段名映射,在解析入口把所有可能出现的字段名归一到内部统一命名;同时把未知动作类型当作告警记录而不是直接抛异常。这样即使遇到新版本,也能在拿到样本后快速适配,而不是程序崩溃后盲猜。
6. 把复盘自动跑起来:输出一页看得懂的问题清单
解析和指标计算都跑通以后,不要停留在「我有一堆数据」的阶段。真正有用的复盘工具,应该把几十局的统计浓缩成一页问题清单,告诉你最该改的三个动作是什么。我会让脚本输出一张Markdown表格,按「扣分贡献度」排序——比如副露后退率最高、立直和牌率低于平均水平、早巡无番立直占比过高,每一项都直接关联到具体牌谱ID,方便回看。
那份清单的关键字段就三列:问题类型、相关对局数、典型牌谱ID。每一次复盘从清单入手,而不是从随机翻牌谱入手,效率会高很多。写完这套流程之后,我才真正发现自己最大的问题不是判断力,而是晚巡无安全牌硬立直——这个结论在战绩页里根本看不出来,只有把立直收益和巡目、现物数放在一起统计才暴露出来。
如果你也是来给自己找改进方向的,建议先跑100局的量,再开始看统计。样本太少时,任何指标都会被一两局极端对局带偏,这是我踩过的坑。希望这套思路能帮到你,让它成为你复盘习惯里自动跑的那一步。
本文还有配套的精品资源,点击获取