
唉明年才能打舞萌了。相信不少音游玩家在面临升学、毕业、搬家、机厅搬迁或者单纯因为工作忙的时候都有过这种体验。明明心里还记着上次打到哪首谱面、差多少分才能推上 SS却只能眼睁睁看着日历翻过一天又一天。与其让这股劲儿慢慢冷掉不如趁这段空窗期用技术做点真正有价值的事情写一套属于自己的舞萌 DX 训练数据管理工具。这篇文章不打算讲什么高深算法而是带着大家用 Python 和 SQLite 搭建一个可以本地运行的成绩记录与分析系统。你可以把之前打过的每一首歌、每一次分数、准确率、评级都录入进去然后通过图表直观看到自己的成长曲线甚至让程序根据历史成绩推荐下一阶段最该练的曲目。等到明年真正回到机厅你不再是凭着模糊记忆去选歌而是拿着数据说话该推哪张图、该刷哪个准度一目了然。无论你是 Python 初学者还是对音游数据管理感兴趣的玩家都可以跟着文章把项目跑起来。代码会分模块讲解每一段都能复制运行最后还会给出常见问题和工程建议。下面我们就从背景概念开始一步步把它搭起来。1. 背景与核心概念1.1 为什么需要一套成绩管理工具舞萌 DX 是世嘉推出的街机音乐游戏在国内一般被玩家称作“舞萌”或者“中二节奏”。它的玩法是通过触摸屏幕上的按键和中央区域完成各种谱面判定从 Perfect 到 Miss 分成了多个层级最终会得到一个分数和准确率同时给出 SS、SSS、AAA 这类评级。街机游戏和手机音游最大的不同在于你必须在有机器的地方才能游玩。很多玩家并不是每天都能去机厅可能是距离太远、要备战考试、或者机厅最近停业装修。这种时候练习进度很容易断档。等到再回到机器前发现自己连以前能过的谱面都生疏了。如果我们在无法游玩的时间里把历史成绩整理成结构化数据再利用图表观察分数和准确率的变化就能非常有针对性地安排下一次练习。比如你可以发现某首歌的分数一直在提高但准确率卡在 96% 左右突破不上去你可能还会发现某类谱面的平均成绩明显偏低说明读谱能力存在短板。这些单靠大脑记忆是很容易忽略的。1.2 核心概念拆解在开始写代码之前先明确几个核心概念。歌曲信息Song包括歌曲名称、艺术家、难度等级、分类等。难度等级是音游里很重要的一个维度舞萌 DX 的难度通常用数字表示还有类似 13、14 这样的细分。记录难度等级后你可以观察自己在不同难度段位的表现。一次游玩成绩Score Record指你在某一天打某一首歌得到的分数、准确率、评级还可以加上备注比如“这次是练习长条”“尾杀爆发力不够”等。练习计划Practice Plan你可以给自己设定目标比如“下个月把某首歌刷到 SS”把这个目标记录到表里方便后续对照。用数据库管理这些数据比用 Excel 或者备忘录更有优势数据库支持查询、聚合、排序能轻松算出某首歌的历史最高分、平均准确率也能按时间排序生成趋势图。本文采用 SQLite是因为它是 Python 内置支持的轻量级数据库不需要单独安装服务非常适合本地个人工具。2. 环境准备与版本说明2.1 环境要求本项目不需要太复杂的运行环境主要依赖 Python 标准库和 Matplotlib。操作系统Windows、macOS、Linux 均可。Python 版本建议 3.8 以上。示例代码中使用了 f-string 和类型标注3.8 完全可以支持。如果你用的是 3.10 以上则没有任何兼容问题。SQLitePython 内置了sqlite3模块不需要单独安装数据库服务。Matplotlib用于生成趋势图需要手动安装。如果你使用 AnacondaMatplotlib 通常已经预装。如果是纯净的 Python 环境在命令行执行pip install matplotlib建议在项目目录下创建虚拟环境避免依赖混乱。创建并激活虚拟环境的命令如下python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate注意sqlite3是标准库不需要通过 pip 安装。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。2.2 项目结构规划为了让代码职责清晰我们采用模块化设计项目目录结构如下maimai_training/ │ ├── main.py # 命令行交互入口 ├── db.py # 数据库连接与初始化 ├── service.py # 业务逻辑包括歌曲与成绩的增删查 ├── analyze.py # 数据分析和图表绘制 └── maimai_training.db # SQLite 数据库文件首次运行后自动生成这里先不引入包管理工具保持代码简单方便新手理解。如果你已经有工程化经验可以在此基础上继续封装。3. 核心设计拆解3.1 数据表设计一个合格的数据表设计能让后续查询变得非常轻松。我们设计三张表第一张是歌曲表song用来存储歌曲的基础信息。字段名类型说明idINTEGER 主键自增主键nameTEXT歌曲名称artistTEXT作曲家 / 歌手levelTEXT难度等级比如 12、13categoryTEXT分类比如“原创”“版权曲”added_atTEXT添加时间默认当前时间第二张是成绩表score_record记录每一次游玩的分数。字段名类型说明idINTEGER 主键自增主键song_idINTEGER对应歌曲表的 id外键play_dateTEXT游玩日期格式为 YYYY-MM-DDscoreINTEGER分数accuracyREAL准确率比如 97.5 表示 97.5%rankTEXT评级比如 SS、SSSnotesTEXT备注记录手感和心得created_atTEXT录入时间默认当前时间第三张表是练习计划表practice_plan用来管理目标。字段名类型说明idINTEGER 主键自增主键song_idINTEGER目标歌曲target_dateTEXT期望完成日期target_rankTEXT目标评级statusTEXT状态pending 表示未完成done 表示已完成created_atTEXT创建时间为什么一定要用外键因为成绩和计划都关联到歌曲。如果没有外键很容易出现录成绩时把歌曲 ID 写错导致数据混乱。SQLite 支持外键约束在创建表时声明FOREIGN KEY即可。需要注意SQLite 默认不开启外键约束但我们的代码只是通过外键保持逻辑上的关联没有开启严格约束也可以正常工作。3.2 数据库操作模块设计数据库模块db.py主要做三件事提供统一的数据库连接。初始化表结构。在连接对象上设置row_factory让查询结果可以像字典一样通过字段名访问。使用 SQLite 时一个容易被忽略的细节是每次操作完要关闭连接。个人工具虽然不会遇到高并发问题但长时间不关闭连接也可能导致文件锁或数据未提交。所以我们每次都在函数内获取连接操作完成后关闭。另外SQLite 支持事务。默认情况下执行INSERT、UPDATE、DELETE后需要调用commit()才能持久化。如果不调用程序正常退出时数据可能丢失。在初始化表结构时因为CREATE TABLE IF NOT EXISTS语句也需要提交所以同样要调用commit()。3.3 日期与成绩处理策略存储日期时统一使用YYYY-MM-DD这种文本格式天然支持按字典序排序。比如2025-06-01小于2025-06-15所以按字符串排序就能保证时间顺序。在绘图时直接把日期作为 x 轴数据Matplotlib 也能正常渲染。准确率建议使用浮点数存储 0 到 100 之间的数值比如 97.5。不要把百分号存进去否则计算平均值时会很麻烦。分数则是整数。评级可以存成文本比如SSS、SS、S、A等。如果在录入时输错了日期比如写成了2025/06/01会导致排序错乱。所以代码中应该让用户按YYYY-MM-DD格式输入在后端可以做简单的正则校验。不过为了保持示例简洁我们只做基本提示不强制校验。在最佳实践章节会提到更完善的校验思路。4. 完整实战案例4.1 创建项目结构先在你的工作目录下创建一个文件夹命名为maimai_training并在这个文件夹里新建db.py、service.py、analyze.py、main.py四个文件。按上一节设计我们首先编写数据库初始化模块。4.2 编写数据库模块 db.py打开db.py写入以下代码# db.py import sqlite3 DB_PATH maimai_training.db def get_connection(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row return conn def init_db(): conn get_connection() cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS song ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, artist TEXT, level TEXT, category TEXT, added_at TEXT DEFAULT (datetime(now, localtime)) ) ) cursor.execute( CREATE TABLE IF NOT EXISTS score_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, song_id INTEGER NOT NULL, play_date TEXT NOT NULL, score INTEGER NOT NULL, accuracy REAL NOT NULL, rank TEXT, notes TEXT, created_at TEXT DEFAULT (datetime(now, localtime)), FOREIGN KEY (song_id) REFERENCES song(id) ) ) cursor.execute( CREATE TABLE IF NOT EXISTS practice_plan ( id INTEGER PRIMARY KEY AUTOINCREMENT, song_id INTEGER NOT NULL, target_date TEXT, target_rank TEXT, status TEXT DEFAULT pending, created_at TEXT DEFAULT (datetime(now, localtime)), FOREIGN KEY (song_id) REFERENCES song(id) ) ) conn.commit() conn.close()这段代码中用到了datetime(now, localtime)这是 SQLite 自带的函数会生成当前本地时间。如果你希望统一使用 UTC 时间可以去掉localtime。row_factory sqlite3.Row是让查询结果支持像字典一样访问的关键。后续我们在业务层写row[name]就能拿到字段值比用下标访问清晰很多。4.3 编写业务逻辑 service.py业务模块负责与数据库交互对外提供函数。新建service.py编写以下代码# service.py from db import get_connection def add_song(name, artist, level, category): conn get_connection() cursor conn.cursor() cursor.execute( INSERT INTO song (name, artist, level, category) VALUES (?, ?, ?, ?), (name, artist, level, category) ) conn.commit() song_id cursor.lastrowid conn.close() return song_id def add_score(song_id, play_date, score, accuracy, rank, notes): conn get_connection() cursor conn.cursor() cursor.execute( INSERT INTO score_record (song_id, play_date, score, accuracy, rank, notes) VALUES (?, ?, ?, ?, ?, ?) , (song_id, play_date, score, accuracy, rank, notes)) conn.commit() conn.close() def list_songs(): conn get_connection() cursor conn.cursor() rows cursor.execute(SELECT id, name, level, category FROM song ORDER BY id).fetchall() conn.close() return rows def get_scores_by_song(song_id): conn get_connection() cursor conn.cursor() rows cursor.execute( SELECT play_date, score, accuracy, rank, notes FROM score_record WHERE song_id ? ORDER BY play_date , (song_id,)).fetchall() conn.close() return rows def get_best_score_by_song(song_id): conn get_connection() cursor conn.cursor() row cursor.execute( SELECT play_date, score, accuracy, rank, notes FROM score_record WHERE song_id ? ORDER BY score DESC, accuracy DESC LIMIT 1 , (song_id,)).fetchone() conn.close() return row def recommend_practice(threshold95.0, limit5): conn get_connection() cursor conn.cursor() rows cursor.execute( SELECT s.id, s.name, s.level, MAX(r.accuracy) AS best_accuracy, MAX(r.score) AS best_score FROM song s JOIN score_record r ON r.song_id s.id GROUP BY s.id HAVING best_accuracy ? ORDER BY best_accuracy ASC LIMIT ? , (threshold, limit)).fetchall() conn.close() return rows代码中所有 SQL 都使用了参数化查询也就是问号占位符。这样做能够避免 SQL 注入风险哪怕个人工具不面对恶意用户这也是值得坚持的习惯。recommend_practice函数会找出所有“历史最高准确率”低于指定阈值默认 95%的歌曲按准确率从低到高排序返回前几条。你可以把它理解成“待提升曲目推荐”。注意这里的MAX(accuracy)和MAX(score)是分别求最大值它们可能来自不同场次的记录。在推荐场景下我们更关心的是整体上限所以这种做法可以接受。如果你需要拿到“历史最高分那条记录对应的准确率”就要像get_best_score_by_song那样用排序加LIMIT 1来实现。4.4 编写可视化模块 analyze.py可视化是整个工具最直观的部分。新建analyze.py# analyze.py import matplotlib matplotlib.use(Agg) import matplotlib.pyplot as plt from db import get_connection from service import get_scores_by_song def plot_song_trend(song_id, name): rows get_scores_by_song(song_id) if not rows: print(该歌曲暂无成绩记录) return dates [row[play_date] for row in rows] scores [row[score] for row in rows] accuracies [row[accuracy] for row in rows] fig, ax1 plt.subplots(figsize(10, 5)) ax1.plot(dates, scores, markero, color#1f77b4, labelScore) ax1.set_xlabel(Play Date) ax1.set_ylabel(Score, color#1f77b4) ax1.tick_params(axisy, labelcolor#1f77b4) ax1.legend(locupper left) ax2 ax1.twinx() ax2.plot(dates, accuracies, markers, color#ff7f0e, labelAccuracy %) ax2.set_ylabel(Accuracy (%), color#ff7f0e) ax2.tick_params(axisy, labelcolor#ff7f0e) ax2.legend(locupper right) ax1.set_title(fScore and Accuracy Trend: {name}) fig.tight_layout() plt.savefig(f{name}_trend.png, dpi150) plt.close(fig) print(f图表已保存{name}_trend.png)这里使用了双 y 轴因为分数和准确率的数值范围不同画在同一坐标系中会相互挤压。左轴显示分数右轴显示准确率两条折线能更清楚地反映变化趋势。matplotlib.use(Agg)是为了避免在某些无图形界面的服务器上显示窗口时报错。如果你在自己的电脑上运行希望直接弹出窗口可以去掉这一行并把plt.savefig改成plt.show()。但考虑到很多读者可能在云服务器或远程环境中运行统一使用保存图片的方式更稳妥。4.5 编写命令行入口 main.py现在把各个模块串起来写一个简单的命令行交互程序。新建main.py# main.py from db import init_db from service import ( add_song, add_score, list_songs, get_best_score_by_song, recommend_practice, ) from analyze import plot_song_trend def print_songs(songs): if not songs: print(当前没有歌曲请先添加歌曲。) return for s in songs: print(f{s[id]}. {s[name]} | {s[level]} | {s[category]}) def main(): init_db() print( 舞萌DX 训练数据管理工具 ) while True: print(\n请选择操作) print(1. 添加歌曲) print(2. 录入成绩) print(3. 查看歌曲列表) print(4. 查看单曲最高分) print(5. 生成成绩趋势图) print(6. 推荐练习曲目) print(0. 退出) choice input(请输入数字).strip() if choice 1: name input(歌曲名称).strip() artist input(歌手/作曲家).strip() level input(难度等级如 12, 13).strip() category input(分类可选).strip() song_id add_song(name, artist, level, category) print(f已添加歌曲ID{song_id}) elif choice 2: song_id input(歌曲ID).strip() play_date input(游玩日期YYYY-MM-DD).strip() score int(input(分数).strip()) accuracy float(input(准确率如 97.5).strip()) rank input(评级SSS/SS/S/A等可选).strip() notes input(备注可选).strip() add_score(song_id, play_date, score, accuracy, rank, notes) print(成绩已录入) elif choice 3: print_songs(list_songs()) elif choice 4: song_id input(歌曲ID).strip() best get_best_score_by_song(song_id) if best: print(f日期{best[play_date]}) print(f分数{best[score]}) print(f准确率{best[accuracy]}%) print(f评级{best[rank]}) else: print(该歌曲暂无成绩记录) elif choice 5: song_id input(歌曲ID).strip() name input(歌曲名称用于图表标题和文件名).strip() plot_song_trend(song_id, name) elif choice 6: threshold input(推荐准确率阈值默认95).strip() threshold float(threshold) if threshold else 95.0 limit input(推荐数量默认5).strip() limit int(limit) if limit else 5 rows recommend_practice(threshold, limit) if rows: print(建议优先练习以下曲目) for r in rows: print(f{r[id]}. {r[name]} | {r[level]} | f最高准确率 {r[best_accuracy]}% | 最高分 {r[best_score]}) else: print(没有需要推荐的曲目继续保持) elif choice 0: print(等待期结束期待你回归机厅时实力大增) break else: print(请输入有效数字) if __name__ __main__: main()这个入口程序虽然简单但已经把核心功能全部串联起来。你可以直接运行python main.py然后按照菜单提示操作。4.6 运行与验证为了让读者直观看到运行效果我们模拟一组数据。你可以先选择菜单 1 添加一首歌比如歌曲名称测试曲目 歌手/作曲家测试作者 难度等级13 分类版权曲添加成功后会输出已添加歌曲ID1。然后选择菜单 2 录入两次成绩。第一次歌曲ID1 游玩日期2025-01-10 分数880000 准确率91.2 评级S 备注初见尾杀掉血第二次歌曲ID1 游玩日期2025-02-14 分数920000 准确率94.8 评级SS 备注手感不错继续推接着选择菜单 4 查看单曲最高分会得到类似输出日期2025-02-14 分数920000 准确率94.8% 评级SS再选择菜单 5 生成趋势图输入歌曲 ID 和名称程序会生成一个名为测试曲目_trend.png的图片文件。打开图片后你能看到两条折线分数和准确率都在上升说明练习有效。最后试试菜单 6把阈值设为 95程序会推荐测试曲目因为当前最高准确率只有 94.8%低于 95%。这个结果非常直观地告诉你下次回机厅优先刷这首曲子目标是准度超过 95%。5. 常见问题与排查思路在实际运行时你可能会遇到一些问题。下面把高频问题整理成表格方便对照排查。问题现象常见原因解决思路运行python main.py提示ModuleNotFoundError: No module named matplotlib环境未安装 Matplotlib执行pip install matplotlib如果使用虚拟环境请先激活虚拟环境图表中文字显示为方块系统缺少中文字体或者 Matplotlib 未正确配置字体绘图代码中改用英文标签或者查阅 Matplotlib 字体配置方法安装中文字体后设置plt.rcParams[font.sans-serif]终端输出中文乱码操作系统终端默认编码不是 UTF-8Windows 下可在启动 Python 前执行chcp 65001或在代码开头加import sys; sys.stdout.reconfigure(encodingutf-8)插入成绩后程序退出再打开数据丢失可能是没有调用commit()检查add_score函数中是否执行了conn.commit()这是持久化的关键图表中日期顺序错乱输入的日期格式不统一比如混用了2025/01/01和2025-01-02统一使用YYYY-MM-DD格式建议在录入时做格式校验sqlite3.OperationalError: no such table没有先执行init_db()确保在main()开头调用了init_db()或者在第一次操作前手动执行一次初始化函数使用get_best_score_by_song查出的最高分对应准确率不对之前使用了MAX(score)和MAX(accuracy)分开聚合改用ORDER BY score DESC, accuracy DESC LIMIT 1获取完整记录如果你遇到了别的问题建议先检查是否因为版本差异导致。本文的代码基于 Python 3.8 编写如果你使用的是更早的 Python 版本可能需要调整 f-string 或类型标注的写法。6. 最佳实践与工程建议当你把基础版本跑通后可以继续从下面几个方向优化让工具变得更健壮。6.1 使用虚拟环境和依赖锁定推荐在项目目录使用虚拟环境避免和系统 Python 环境互相影响。如果你希望分享项目给别人可以生成requirements.txtpip freeze requirements.txt或者手动保持简洁matplotlib3.5这样别人克隆项目后只需要执行pip install -r requirements.txt就能安装依赖。6.2 数据库备份SQLite 是一个单文件数据库备份非常简单。只需要把maimai_training.db文件复制一份即可。你可以写一个简单的备份脚本每次启动时把数据库备份到带日期的文件里。如果你把代码放到 Git 仓库中注意不要把数据库文件提交进去因为游戏记录可能包含个人隐私而且二进制文件不利于版本对比。6.3 输入校验与异常处理当前代码通过int()和float()转换输入值如果用户输入的不是数字程序会直接抛出ValueError体验不太好。更专业的做法是写一个输入函数捕获异常并提示重新输入。比如def input_int(prompt): while True: value input(prompt).strip() try: return int(value) except ValueError: print(请输入整数)同样日期格式也可以用datetime.strptime()校验确保用户输入的是YYYY-MM-DD。6.4 增加删除与修改功能随着数据量增加录入错误的情况几乎一定会发生。建议增加两个功能删除一条成绩记录。修改一条成绩记录的字段。平时可以先实现一个“删除记录”的函数用成绩 ID 定位记录执行删除前让用户二次确认。这个操作要谨慎毕竟成绩数据无法从其他地方恢复。在生产环境中任何删除操作都应该先备份。6.5 对抗 SQL 注入文章中的代码已经全部使用参数化查询这是一个非常重要的习惯。无论面对什么 SQLite 或 MySQL都不要用字符串拼接的方式把用户输入直接拼进 SQL 语句。比如# 错误示例不要这样做 sql fSELECT * FROM song WHERE name {name} cursor.execute(sql)正确的做法是cursor.execute(SELECT * FROM song WHERE name ?, (name,))6.6 图为复盘服务图表不只是为了好看。建议每次去机厅玩完后把新成绩录入工具并生成趋势图然后复盘分数上升但准确率下降可能是在追求高分时忽视了节奏稳定。分数持平但准确率上升说明判定的纯度提高了这是好事。某首歌长期没有进步可以考虑换一个练习思路不要死磕。把图片按日期保存或者使用 git 管理图片历史一段时间后回看你会发现自己对音乐的感知和读谱能力都在变化。这种数据驱动的复盘方式是快速提升的有效路径。6.7 不要触及版权和违规边界如果你想把工具做得更丰富比如自动从某个网站抓取曲库数据请务必确认来源是否允许。个人学习用途的数据记录没有问题但未经授权批量抓取和二次分发内容可能带来法律风险。建议保持工具的本地性和个人性只记录自己手动输入的数据。另外本文不涉及任何模拟器或破解内容。等待期想保持手感可以通过听歌、读谱、指法练习等方式进行不推荐使用未经授权的模拟方式。音游的乐趣在于现场互动和氛围把这些数据准备好明年再去机厅才更有意义。7. 总结与学习路线这篇文章从一个很常见的玩家状态切入——“明年才能打舞萌了”然后带你完成了一个切实可用的训练数据管理工具。你学会了设计三张关系型数据表歌曲表、成绩表、练习计划表。使用 Python 内置的sqlite3模块操作 SQLite 数据库。通过参数化查询防止 SQL 注入。使用 Matplotlib 生成双轴趋势图。编写一个简单的命令行交互程序串联完整业务流程。如果你还想继续深入可以考虑以下扩展方向给程序增加图形界面比如基于 Tkinter 或 PySide6 做一个桌面端成绩录入表单。引入 Pandas 做更细粒度的数据聚合比如按月统计平均准确率。增加通知功能当练习计划到期时自动提醒。把数据导出为 CSV 或 Excel方便你分享给朋友或导入其他工具。当你明年真正回到机厅站在熟悉的机器前不需要纠结该打什么只需要打开这份数据找到最高准确率还不到目标的那首歌开始练就行。所有等待的时间都会被这些积累变成下一次进步的底气。加油明年机厅见。