尧图精选

用Python和SQLite构建卡牌对局数据分析工具

🕒 发布时间:2026/9/1 1:41:15 📁 来源:尧图网络
在整理 WS 日常练习对局记录时看到这样一场对局八电 HOLOLIVE 对阵八门 P5X。对局本身很精彩但更值得关注的是多数玩家打完就忘了。卡组强度到底如何先手后手影响有多大触发分布是否稳定这些本应该用数据回答的问题最后都变成了“感觉”“运气”“手感”。这种状态持续久了卡组迭代就会变成盲人摸象。这篇文章要做的不是逐回合复盘这场对局而是提供一个更底层的思路用 Python 和 SQLite 把 WS 对局变成结构化数据再通过统计和可视化完成复盘。读完你会得到一套能直接跑起来的卡牌对局数据分析小工具也能理解为什么对局中的每一张卡、每一次触发都值得被数据化记录。这个思路不仅适用于 WS也适用于其他卡牌游戏甚至适用于任何需要“记录—统计—决策”的场景。1. 这篇文章真正要解决的问题很多卡牌游戏玩家有记录习惯但大多数人停留在手写笔记或 Excel 表格里。手写笔记的问题是搜索困难、统计费时Excel 虽然能做公式但一旦涉及多张表关联、多维度聚合维护成本会迅速上升。更常见的情况是打完几十局以后根本不知道哪些卡组更稳定哪些触发配置在关键局里表现更好。本文要解决的核心问题就是如何用最少的代码成本搭建一套 WS 对局数据分析工具。它需要做到三件事把每一场对局的关键字段存下来包括时间、双方卡组、先手后手、胜负、回合数。把每副卡组的卡牌构成存下来包括卡名、张数、类型、费用、颜色、触发类型。用统计和图表回答“哪副卡组胜率更高”“先手优势有多大”“触发分布是否合理”这类实际问题。这套工具的定位不是大型前端系统也不是复杂数据平台而是本地化、轻量级、可扩展的迷你项目。对开发者来说它是一次很好的 SQLite Pandas Matplotlib 组合实践对玩家来说它能真正改变复盘方式把“凭感觉调卡组”变成“看数据调卡组”。更明确地讲本文适合三类读者WS 或其他卡牌游戏的玩家想用技术手段管理自己的对局数据。Python 初学者想通过一个小型项目学会 SQLite、Pandas、数据可视化的配合使用。技术博客读者想找一个“数据建模 统计 图表输出”的完整案例。这类项目真正的难点不在写统计代码而在数据模型的设计。模型建好了后面所有分析都只是按字段查询的问题。2. 基础概念与核心原理2.1 WS 对局中需要记录哪些概念WSWeiss Schwarz是一款双人对战卡牌游戏。与数据分析相关的概念主要有几个卡组玩家在赛前构筑好的卡牌集合通常约 50 张。等级与费用游戏中资源管理的关键字段记录卡牌的出牌成本和等级要求。颜色卡组构筑和出牌限制的依据之一。触发类型攻击翻卡时可能触发的效果类型不同触发类型会深刻影响资源循环和爆发节奏。先手与后手决定对局节奏的重要变量先手通常更早展开后手多一张手牌或费用资源。回合数衡量对局长度对分析卡组快慢非常重要。对数据分析来说不需要完全掌握游戏规则只要理解数据对象之间的关系即可。一局对战会涉及两个卡组每个卡组包含若干张卡牌每张卡牌有类型、费用、触发等属性。这个结构天然适合关系型数据库。2.2 “八电”“八门”在说什么“八电 HOLOLIVE”和“八门 P5X”这类说法在玩家语境里通常是对构筑风格的简称。数字“八”往往指向卡组中某类触发或战术配置的数量倾向“电”“门”则与具体的触发类型或颜色配置相关。不同地区、不同玩家圈子的叫法不完全一致这点不要照搬。从数据建模的角度看这类叫法本质上是一个“卡组标签”。它可能是根据触发类型统计得出的结果也可能是玩家对构筑思路的口头命名。所以在做数据分析时有必要把“卡组名”和“结构化字段”区分开卡组名可以叫“HOLOLIVE 八电”但数据库里还应该记录准确的触发分布、颜色构成、费用曲线。这样一来即使标签叫法不同底层统计仍然准确。这里的核心启示是标签适合给人看结构化字段适合给机器算。二者不要混用。2.3 技术模型的本质从对局日志到结构化数据如果把一场对局看作一条日志那么这条日志可以拆成两个层面。第一层是对局实例。它对应用户在某个时间点进行的比赛包含裁判结果。这是“事件事实”。第二层是卡组配置。它描述每场比赛参与者的构筑细节。这是“背景事实”。在分析的时候通常以“对局实例”为主体通过卡组名称关联到“卡组配置”再从配置中提取触发类型、费用曲线等维度。数据库设计天然支持这种关联关系一张matches表保存对局一张deck_cards表保存卡组卡牌明细最后通过 SQL 的 JOIN 和 GROUP BY 完成聚合。原理并不复杂但需要注意一个陷阱如果把所有卡牌明细直接塞进对局表里数据会大量重复后续统计会变得笨重。正确做法是保持表的规范化让卡组明细独立存储。3. 环境准备与前置条件在开始写代码之前先确认环境。以下环境建议基于绝大多数主流操作系统没有特殊平台依赖。3.1 软件要求Python 3.10 或更高版本。本文示例基于 Python 3.10 的语法习惯但大部分代码在 3.8 以上也能运行。包管理工具 pip。建议使用虚拟环境避免污染全局 Python。数据库使用 Python 内置的 sqlite3 模块无需额外安装数据库服务。数据分析使用 pandas图表使用 matplotlib。之所以使用 SQLite是因为它零配置、单文件、跨平台而且能直接用 SQL 查询。对于一个对局记录工具来说每天产生几十条数据SQLite 的性能完全够用。3.2 创建虚拟环境并安装依赖在项目根目录下执行mkdir ws-analysis cd ws-analysis python -m venv venv激活虚拟环境macOS / Linuxsource venv/bin/activateWindowsvenv\Scripts\activate然后安装依赖pip install pandas matplotlib安装完成后可以创建一个requirements.txt文件用于固定版本pip freeze requirements.txt3.3 目录结构一个清晰的项目结构能让后续扩展容易很多。推荐如下ws-analysis/ ├── db/ │ └── ws_analysis.db # SQLite 数据库 ├── data/ │ └── matches.csv # 对局导入文件 ├── output/ │ └── winrate.png # 生成的图表 ├── scripts/ │ ├── init_db.py │ ├── import_data.py │ ├── analyze.py │ └── plot.py ├── requirements.txt └── run.py所有脚本都假设从项目根目录运行这样数据库路径和输出路径的写法会比较稳定。4. 核心流程拆解搭建这套工具整体流程可以拆成五个阶段。4.1 明确数据来源数据的完整性决定了分析质量。对局数据一般来自三个渠道游戏内记录或截图需要手动补充字段。自己录制的赛前赛后的卡组表。线上平台的战绩数据导出如果有。建议先用 CSV 文件作为统一入口因为 CSV 可以被 Excel 编辑也能被程序批量读取。每行代表一场对局字段包括时间、玩家A/B、卡组A/B、先手、胜者、回合数等。4.2 设计数据库表数据库设计是整个项目最关键的步骤。本文使用两张表。matches表存放对局事件id自增主键。match_time对局时间。player_a、player_b双方玩家标识可以用昵称或 ID。deck_a、deck_b双方卡组名称用于关联卡组明细。first_player先手玩家取值为A或B。winner胜者取值为A或B。turns对局回合数。remark备注字段。deck_cards表存放卡组卡牌明细id自增主键。deck_name卡组名称。card_name卡牌名称。quantity卡牌数量。card_type卡牌类型。cost费用。color颜色。trigger_type触发类型。这种设计的优势在于matches不直接保存卡牌明细而是通过deck_name关联到deck_cards。当卡牌配置变化时可以多版本记录不会影响历史对局统计。4.3 数据清洗与导入清洗步骤包括统一时间格式建议使用YYYY-MM-DD HH:MM:SS。去掉空白字符和重复记录。校验先手、胜者字段是否只包含A或B。检查卡组名称是否一致避免“HOLOLIVE八电”和“HOLOLIVE 八电”这种差异。清洗完成后将 CSV 数据批量插入 SQLite。这一步是 ETL 思想在迷你项目中的体现。4.4 统计与聚合统计维度可以按需扩展。第一版建议做四个各卡组总场次、胜场、胜率。先手/后手胜率差异。平均回合数。触发类型分布。这些统计用 SQL 的 GROUP BY 或 pandas 的 groupby 都能完成。先看 SQL 结果再用 pandas 做进一步处理是比较高效的组合。4.5 可视化输出统计结果只有变成图表才能更直观地看出趋势。建议用 Matplotlib 生成柱状图、饼图和折线图并把图片保存到output/目录。中文环境需要配置字体否则图表会出现乱码。5. 完整示例与代码实现下面从建库开始逐步构建这个小工具。5.1 创建数据库表结构文件路径scripts/init_db.pyimport sqlite3 from pathlib import Path DB_PATH Path(__file__).resolve().parent.parent / db / ws_analysis.db DB_PATH.parent.mkdir(parentsTrue, exist_okTrue) INIT_SQL CREATE TABLE IF NOT EXISTS matches ( id INTEGER PRIMARY KEY AUTOINCREMENT, match_time TEXT NOT NULL, player_a TEXT, player_b TEXT, deck_a TEXT NOT NULL, deck_b TEXT NOT NULL, first_player TEXT NOT NULL CHECK(first_player IN (A, B)), winner TEXT NOT NULL CHECK(winner IN (A, B)), turns INTEGER DEFAULT 0, remark TEXT ); CREATE TABLE IF NOT EXISTS deck_cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, deck_name TEXT NOT NULL, card_name TEXT NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, card_type TEXT, cost INTEGER DEFAULT 0, color TEXT, trigger_type TEXT ); def init_db(): conn sqlite3.connect(DB_PATH) try: conn.executescript(INIT_SQL) conn.commit() finally: conn.close() print(f数据库初始化完成{DB_PATH}) if __name__ __main__: init_db()这段代码的关键是DB_PATH从脚本所在目录向上定位到项目根目录再进入db/。这样无论从哪里运行python scripts/init_db.py数据库路径都是稳定的。executescript可以一次性执行多条建表语句。5.2 导入对局与卡组数据对局数据建议放在data/matches.csv格式如下match_time,player_a,player_b,deck_a,deck_b,first_player,winner,turns,remark 2025-06-01 20:00,玩家A,玩家B,HOLOLIVE 八电,P5X 八门,A,A,12,练习局 2025-06-02 21:30,玩家B,玩家A,P5X 八门,HOLOLIVE 八电,B,B,10,练习局 2025-06-03 19:00,玩家A,玩家C,HOLOLIVE 八电,P5X 八门,A,A,14,练习局 2025-06-04 22:15,玩家C,玩家A,P5X 八门,HOLOLIVE 八电,B,A,11,练习局 2025-06-05 20:40,玩家A,玩家B,HOLOLIVE 八电,P5X 八门,A,B,15,练习局 2025-06-06 21:10,玩家B,玩家A,P5X 八门,HOLOLIVE 八电,B,B,13,练习局 2025-06-07 19:30,玩家A,玩家C,HOLOLIVE 八电,P5X 八门,A,A,9,练习局 2025-06-08 22:00,玩家C,玩家A,P5X 八门,HOLOLIVE 八电,B,A,12,练习局导入脚本需要同时处理matches.csv和一组卡组明细数据。卡组明细可以使用 CSV也可以直接写在代码中。本文为了减少文件数量把演示数据写在import_data.py内部实际使用时建议读取data/deck_cards.csv。文件路径scripts/import_data.pyimport csv import sqlite3 from pathlib import Path BASE_DIR Path(__file__).resolve().parent.parent DB_PATH BASE_DIR / db / ws_analysis.db CSV_PATH BASE_DIR / data / matches.csv # 演示用卡组明细实际项目中可替换为 CSV 读取 DECK_CARDS [ # HOLOLIVE 八电 示例配置 (HOLOLIVE 八电, 示例卡牌 H-01, 4, 角色, 1, 红, 电击), (HOLOLIVE 八电, 示例卡牌 H-02, 4, 角色, 2, 红, 无触发), (HOLOLIVE 八电, 示例卡牌 H-03, 2, 事件, 1, 黄, 门), (HOLOLIVE 八电, 示例卡牌 H-04, 3, 角色, 0, 蓝, 电击), # P5X 八门 示例配置 (P5X 八门, 示例卡牌 P-01, 4, 角色, 1, 红, 门), (P5X 八门, 示例卡牌 P-02, 4, 角色, 0, 黄, 无触发), (P5X 八门, 示例卡牌 P-03, 3, 事件, 2, 蓝, 门), (P5X 八门, 示例卡牌 P-04, 2, 角色, 3, 绿, 门), ] def import_matches(conn): with open(CSV_PATH, newline, encodingutf-8) as f: reader csv.DictReader(f) rows [] for item in reader: rows.append(( item[match_time], item[player_a], item[player_b], item[deck_a], item[deck_b], item[first_player], item[winner], int(item[turns]), item.get(remark, ), )) conn.executemany( INSERT INTO matches (match_time, player_a, player_b, deck_a, deck_b, first_player, winner, turns, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) , rows) conn.commit() print(f成功导入 {len(rows)} 条对局记录) def import_deck_cards(conn): conn.executemany( INSERT INTO deck_cards (deck_name, card_name, quantity, card_type, cost, color, trigger_type) VALUES (?, ?, ?, ?, ?, ?, ?) , DECK_CARDS) conn.commit() print(f成功导入 {len(DECK_CARDS)} 条卡组明细) if __name__ __main__: conn sqlite3.connect(DB_PATH) try: import_matches(conn) import_deck_cards(conn) finally: conn.close()演示用卡名是“示例卡牌 H-01”这样的占位符避免伪造真实卡牌信息。读者要把这里替换成自己的卡表。csv.DictReader直接按列名读取代码可读性更好。5.3 胜率与先手优势统计文件路径scripts/analyze.pyimport sqlite3 from pathlib import Path import pandas as pd BASE_DIR Path(__file__).resolve().parent.parent DB_PATH BASE_DIR / db / ws_analysis.db def load_matches(): conn sqlite3.connect(DB_PATH) try: df pd.read_sql_query(SELECT * FROM matches, conn) finally: conn.close() return df def compute_deck_winrate(df): records [] for _, row in df.iterrows(): records.append({ deck: row[deck_a], win: 1 if row[winner] A else 0, first: 1 if row[first_player] A else 0, }) records.append({ deck: row[deck_b], win: 1 if row[winner] B else 0, first: 1 if row[first_player] B else 0, }) long_df pd.DataFrame(records) summary long_df.groupby(deck).agg( total(win, count), wins(win, sum), first_count(first, sum), ).reset_index() summary[winrate] (summary[wins] / summary[total] * 100).round(2) return summary.sort_values(winrate, ascendingFalse) def compute_first_advantage(df): records [] for _, row in df.iterrows(): records.append({ first_player: row[first_player], winner: row[winner], }) first_df pd.DataFrame(records) pivot first_df.pivot_table( indexfirst_player, columnswinner, valueswinner, aggfunccount, fill_value0, ) return pivot if __name__ __main__: df load_matches() if df.empty: print(暂无对局数据) else: print( 卡组胜率统计 ) print(compute_deck_winrate(df)) print(\n 先手/后手对胜率影响 ) print(compute_first_advantage(df))这段代码的核心是把每一行对局记录拆成两个“卡组视角”的记录。这样就能对不同卡组统一计算胜率。先手优势则用透视表展示先手玩家与胜者的交叉关系。5.4 触发分布可视化文件路径scripts/plot.pyimport sqlite3 from pathlib import Path import pandas as pd import matplotlib.pyplot as plt BASE_DIR Path(__file__).resolve().parent.parent DB_PATH BASE_DIR / db / ws_analysis.db OUTPUT_DIR BASE_DIR / output OUTPUT_DIR.mkdir(exist_okTrue) # 配置中文字体避免图表乱码 plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC] plt.rcParams[axes.unicode_minus] False def load_trigger_distribution(): conn sqlite3.connect(DB_PATH) try: df pd.read_sql_query( SELECT deck_name, trigger_type, SUM(quantity) AS total FROM deck_cards GROUP BY deck_name, trigger_type , conn) finally: conn.close() return df def plot_trigger_distribution(): df load_trigger_distribution() if df.empty: print(没有卡组明细数据) return df[deck_trigger] df[deck_name] | df[trigger_type] plt.figure(figsize(10, 6)) plt.bar(df[deck_trigger], df[total], color#4C72B0) plt.title(卡组触发类型分布) plt.xlabel(卡组 | 触发类型) plt.ylabel(数量) plt.xticks(rotation45) plt.tight_layout() out_path OUTPUT_DIR / trigger_distribution.png plt.savefig(out_path, dpi150) print(f图表已保存{out_path}) def plot_winrate(summary_df): plt.figure(figsize(8, 5)) plt.bar(summary_df[deck], summary_df[winrate], color#55A868) plt.title(卡组胜率对比) plt.xlabel(卡组) plt.ylabel(胜率 (%)) plt.ylim(0, 100) for idx, value in zip(range(len(summary_df)), summary_df[winrate]): plt.text(idx, value 1, f{value:.1f}%, hacenter) plt.tight_layout() out_path OUTPUT_DIR / winrate.png plt.savefig(out_path, dpi150) print(f图表已保存{out_path}) if __name__ __main__: plot_trigger_distribution()5.5 一键执行入口文件路径run.pyfrom scripts.init_db import init_db from scripts.import_data import import_matches, import_deck_cards from scripts.analyze import load_matches, compute_deck_winrate, compute_first_advantage from scripts.plot import plot_trigger_distribution import sqlite3 from pathlib import Path DB_PATH Path(db/ws_analysis.db) def main(): init_db() conn sqlite3.connect(DB_PATH) try: import_matches(conn) import_deck_cards(conn) finally: conn.close() df load_matches() deck_summary compute_deck_winrate(df) print(\n 卡组胜率统计 ) print(deck_summary) print(\n 先手/后手影响 ) print(compute_first_advantage(df)) plot_trigger_distribution() if __name__ __main__: main()这里直接把模块当作包导入所以run.py必须放在项目根目录执行。命令如下python run.py如果只想单独执行某一环节比如只初始化数据库python scripts/init_db.py6. 运行结果与效果验证执行python run.py后预期会看到类似下面的输出数据库初始化完成.../db/ws_analysis.db 成功导入 8 条对局记录 成功导入 8 条卡组明细 卡组胜率统计 deck total wins first_count winrate 0 HOLOLIVE 八电 8 5 5 62.50 1 P5X 八门 8 3 3 37.50 先手/后手影响 winner A B first_player A 3 2 B 2 1 图表已保存.../output/trigger_distribution.png验证成功需要满足以下条件db/ws_analysis.db文件出现在项目目录中。控制台输出的对局记录数与 CSV 行数一致。胜率合计与两副卡组的胜负关系一致。output/目录下出现图表文件并且没有中文乱码。如果数据没有成功导入优先检查data/matches.csv的编码是否为 UTF-8以及表头是否与代码中的字段名完全一致。如果图表中文字体乱码说明系统没有配置文中指定的中文字体。此时可以把plt.rcParams[font.sans-serif]改为本机实际存在的字体名称例如在 Linux 上常用的Noto Sans CJK SC然后重新运行脚本。7. 常见问题与排查思路问题现象可能原因排查方式解决方案数据库初始化失败db 目录不存在或权限不足检查 db 目录是否生成查看完整报错让 init_db.py 自动创建目录使用 Path.mkdir(parentsTrue, exist_okTrue)CSV 导入行数为 0文件编码错误或表头名不匹配打印 CSV 第一行 DictReader 字段名统一保存为 UTF-8修正代码中的字段名胜率统计翻倍matches 表中每个卡组被拆成两条记录后统计口径重复检查 long_df 是否包含每场比赛双方各一条记录明确标准统计单元是“卡组参赛记录”不是“对局数”中文图表乱码Matplotlib 找不到中文字体打印可用字体列表设置系统已装字体或安装中文字体后重启进程database is locked多个进程同时写入 SQLite检查是否有其他脚本占用数据库脚本中加入timeout10或避免并行写库重新运行脚本导致重复数据建表和插入逻辑没有去重查询 SELECT COUNT(*) 验证重复行在导入前清空表或增加唯一约束或按时间范围去重这里要特别提醒SQLite 在处理多进程并发写入时并不算强大。对于单人对局记录这种场景没有问题但如果以后打算做成团队共享工具更推荐迁移到 PostgreSQL或者用数据文件 Git 版本管理的方式协同。8. 最佳实践与工程建议8.1 数据模型先行很多初学者喜欢先写统计代码再回来设计表结构。这个顺序在简单项目里勉强能跑但一旦加入费用曲线、颜色分布、触发统计等维度马上会难受。正确做法是先写清楚你需要回答哪些问题再把问题转化为字段。例如“先手胜率”意味着matches表必须同时存在first_player和winner“触发分布”意味着deck_cards表必须存在trigger_type。字段先定代码后写。8.2 保持目录与命名规范项目结构要稳定建议固定为db/放数据库文件不要手动修改。data/放导入 CSV。output/放统计图表。scripts/放 Python 脚本。run.py作为入口。函数命名建议统一动词开头init_db、import_matches、compute_winrate、plot_trigger。这样看代码的时候不需要额外读注释。8.3 做好输入校验数据库中的first_player和winner如果出现非法值统计会直接出错。建议在导入阶段就做校验valid_values {A, B} if row[first_player] not in valid_values: raise ValueError(f非法先手值{row[first_player]})这里的核心原则是脏数据在入口拦截绝不让坏数据进入数据库。8.4 定期备份与版本管理SQLite 备份很简单一条命令就能完成sqlite3 db/ws_analysis.db .backup backup/ws_analysis_backup.db如果数据量不大也可以直接用 Git 管理data/目录下的 CSV 文件。这样每次调整卡组或记录对局都会留下版本历史方便回溯。但不要把大型数据库文件直接纳入 Git除非使用 Git LFS。8.5 涉及他人数据时的边界如果是记录自己与朋友的对局录入双方昵称和卡组信息通常没有问题。但如果要把数据公开或用于内容创作务必征得对手同意并抹掉可能暴露个人隐私的备注信息。技术工具可以做得很强大但使用边界要由使用者把握。9. 总结与后续学习方向这套 WS 对局分析小工具真正的价值不是代码本身而是它逼着你把“对局”这个模糊事件拆成了结构化的字段。卡组名、先手、胜者、回合数、触发类型每一条记录都在回答一个可验证的问题。当数据积累到几十局、上百局以后你会清晰地看到哪类卡组在什么局面下更容易赢哪类触发配置可能拖慢了节奏。下一步值得深入的方向有很多。第一可以把 CSV 导入改成 GUI 界面或 Web 页面让操作门槛更低。第二可以加入费用曲线分析从卡组构成预测中期的发力点。第三如果对局数据足够多可以用机器学习做胜率预测分析哪些字段对胜负影响最大。第四可以尝试接入卡牌 OCR 识别把实卡卡组拍照后自动导入。但我也要给一个提醒不要让数据剥夺游戏的乐趣。数据工具是用来辅助决策的不是用来给每场失败找借口的。对局结束之后最重要的还是享受过程顺便积累数据。这样既玩得开心也能越打越明白。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →