简介:《冰与火之舞》自动播放宏是一份面向节奏游戏玩家与 Python 自动化初学者的轻量脚本资源。它围绕节奏点击场景设计,通过计算图块位置与判定时间,生成模拟点击事件,从而实现对应曲目的自动演奏,既可用于缓解高难关卡的反复尝试,也可作为窗口交互、坐标推算与定时触发的练手项目。压缩包内共 3 个文件,包含两个 Python 脚本与一份 Markdown 说明文档:主脚本负责点击事件生成,辅助脚本承担图块计算,README 则记录了灵感来源、开发进度与待办事项,便于理解整体设计脉络。整个资源仅约 2KB,结构精简,适合直接阅读与二次修改。目前已有 7525 人学习下载,对于想了解游戏自动化原理、或需要现成宏脚本的入门开发者来说,是值得参考的样本;阅读源码可进一步掌握图块坐标换算、时间间隔估算和鼠标事件触发等技巧,并可在原有基础上继续完善节拍同步与扩展支持。
1. 这就是个输入重放工具:ADOFAI 自动播放宏做了什么
《冰与火之舞》这款节奏游戏,核心玩法就一句话:在每一拍上做出方向操作,漏一拍就从头再来。手动打高难关,前 30 秒背谱、中间 30 秒手腕酸、最后 5 秒心跳加速,然后翻车在同一个三连拍上。与其反复和手速较劲,不如把"输入动作"从游戏过程中抽出来,做成一条时间轴,用程序按时间戳重放。这就是 ADOFAI 自动播放宏在做的事:监听玩家的有效输入,记录为事件流,再以毫秒级精度回放同一段按键序列。这篇文章要把这条链路每一步怎么做、参数怎么给、故障怎么排都说清楚。适合对 Windows 输入模拟、游戏自动化脚本或节奏类工具开发感兴趣的从业者,也适合第一次接触"录宏再回放"这类思路的开发者照着落地。
2. 先定方案:录制回放、音频检测还是混合路线
2.1 录制回放:最符合"宏"字面的方案
先明确一个概念:所谓宏,录制的是"最终输入事件",不是游戏内部状态。录制回放的思路很简单:你手动打一遍(或者借助一段弱化谱面打通),把鼠标点击、按键事件连同击键持续时间一起记下来。整个过程拆成三步:第一步,用全局钩子监听输入事件;第二步,把事件按发生先后写入磁盘;第三步,启动同一关,从某个对齐点开始逐条重放。每一步都不需要读游戏进程的内存,也不需要知道谱面结构,是典型的输入模仿型工具,对纯应用层开发非常友好。
精细的地方在事件本身。一次完整击键不是一个时间点,而是四个时间点:按下开始、按下结束、释放开始、释放结束。对《冰与火之舞》这种吃"点按"精度的游戏,如果只记录按下瞬间,回放时长按就会失控,球会拐出轨道。我见过不少半成品宏只存了 keyDown 的时间,回放时打得比手滑还惨,就是因为丢了时长信息。一份能用的录制文件必须同时存 down_ms 和 up_ms,回放时按两个时刻分别发出按下与抬起。
录制回放最大的优势是失真度低。变速段、停止拍、反直觉的短休止,都会原样保留在时间轴里,不需要任何分析逻辑。缺点是绕不开"先通关一遍"这个前提,而且录制的样本质量直接决定回放质量。如果录制时手震了一下,多按了半个拍,回放也会忠实再现这个误触。所以录制回放更适合作为自动播放宏的地基,而不是全部。
三条技术路线对比,能更直观地看出各自的边界:
| 方案 | 通关前提 | 变速段表现 | 首次产出时间 | 换关维护成本 |
|---|---|---|---|---|
| 录制回放 | 需要先手动打一次 | 原样保留,最稳 | 几分钟 | 每换一关要重录 |
| 音频节拍检测 | 不需要 | 大概率错拍 | 秒级生成 | 高难关需人工大量修正 |
| 混合路线 | 只录 8 到 16 拍 | 录制段稳,检测段需补 | 十几分钟 | 中等 |
2.2 音频节拍检测:自动化程度更高但更脆
既然游戏的每一次方向操作都由音乐节拍驱动,另一个直觉方案是直接从关卡音频算出所有拍点。思路是:加载音频文件,做节拍检测,得到拍点列表,过滤掉不需要操作的拍,最后生成点击序列。这个方案最大的卖点是不需要通关,你只要手上有关卡 BGM 的 wav 或 ogg,就能自动生成一条时间轴。
但不要高兴太早。节拍检测在纯机械的低速四四拍上很好用,一旦进入变速段和弱起拍就开始变玄学。数组音乐的鼓点密集、合成器音色多,普通能量检测会把底鼓和踩镲全都当成拍点,误检率飙升。你可能拿到一条拍点列表,落在谱面实际转向点上的只有六成。所以纯音频路线适合做谱面规整的中低难度关卡,高难段几乎必然要对拍点做人工删改。
我做这类工具时,音频检测从来不是第一个模块,而是作为生成原稿的辅助。先把拍点算出来,再叠加人工修正,修改成本比逐帧录制低一些,但比录制方案多一层不确定性。要不要在后续版本里引入,取决于你的目标关卡属于哪一类。
2.3 混合路线:用录制校准,用音频补全
最终我实际建议起步的是混合路线。第一步,先录制一段干净的示范输入,长度 8 到 16 拍就好,并不需要打完整关;第二步,把这段输入交给节拍检测器,反推出关卡 BPM 和偏移量;第三步,用音频检测把剩余段落的拍点补全,和录制段拼接成完整时间轴。这样录制段保留了手感细节,检测段补足了长关卡,两端各取所长。
混合路线有一个前置动作必须做:确认录制段与检测段用的是同一个时间基准。录制时时间戳来源是钩子回调的系统计时器,音频检测时拍点来自音频文件采样率换算,两者要真正拼接,需要先通过偏移量做一次对齐。我习惯在录制段结尾故意留一个明显的重拍,让后续程序容易把两段焊在一起。这就引出了下一个实操模块:怎么录出干净的时间轴。
3. 把每一次按键变成时间戳:全局钩子与录制脚本
3.1 用 Windows 低级钩子监听鼠标点击
录制模块的关键是拿到全局输入事件,而且必须在事件发生时第一时间记录,不能靠事后轮询补数据。Windows 下最常用的方案是 SetWindowsHookEx 配合 WH_MOUSE_LL 低级鼠标钩子。低级钩子从系统消息队列里截获鼠标事件,无论游戏窗口是否在前台都能收到,而且不需要注入 DLL,适合用 Python 快速实现。
用 ctypes 直接写钩子需要定义回调函数和消息结构体,比较绕。我习惯用 pynput 库做第一版原型,代码短,事件模型清晰。下面是用 pynput 录制鼠标左键按下和释放的例子:
from pynput import mouse import time events = [] def on_click(x, y, button, pressed): now_ms = int(time.perf_counter() * 1000) events.append({ "button": str(button), "pressed": pressed, "x": x, "y": y, "t_ms": now_ms }) with mouse.Listener(on_click=on_click) as listener: listener.join()这里 on_click 会在每次鼠标按钮状态变化时被调用。pressed 为 True 表示按下,False 表示释放;x、y 是屏幕坐标;now_ms 用 perf_counter 换算成毫秒,它是单调递增的稳定计时器,比 time.time 更适合做时间轴。time.time 可能受系统校时影响跳变,录制长跨度时间轴时会出现乱序时间戳,回放全乱。
3.2 时间轴文件格式与录制的三个参数
录制得到的是原始事件列表,回放模块真正需要的是一个紧凑的时间轴文件。常用格式是 JSON 数组,每个元素只存三个字段:按键名、按下时刻、释放时刻。坐标不写入最终时间轴,因为回放不依赖坐标。格式如下:
[ { "key": "left", "down_ms": 1230, "up_ms": 1296 }, { "key": "left", "down_ms": 1630, "up_ms": 1694 }, { "key": "right", "down_ms": 2030, "up_ms": 2092 } ]down_ms 与 up_ms 是相对录制开始时刻的偏移,所有时间戳减去第一个事件的时间即可得到。除了格式,还有三个录制参数直接决定时间轴能不能用:
| 参数 | 建议值 | 作用 |
|---|---|---|
| 最小脉冲间隔 | 20 毫秒 | 滤除手指抖动产生的重复点击 |
| 最短按压时长 | 30 毫秒 | 保证回放时 SendInput 能识别到按下事件 |
| 录制起点 | 音乐起前 1 秒 | 避免人反应延迟导致第一拍整体滞后 |
最小脉冲间隔解决的是噪声问题。手动录制时鼠标抖动会带来 5 到 15 毫秒的重复按下,回放时这些噪声会变成多余操作。把小于 20 毫秒的相邻事件对过滤掉,保留时间靠前的那一个。最短按压时长解决的是过短点按被系统吞掉的问题,有些关卡要求极快的点按,录制时手指只带出 10 毫秒按压,回放时可能根本触发不了按下事件,强制拉长到 30 毫秒后稳定很多。
3.3 录制前环境准备与热键设计
录制程序不能和游戏抢输入焦点,所以需要给录制脚本配一套独立热键。我用 F8 开始录制、F9 结束录制并保存到指定文件、F10 丢弃当前录制,三个键都通过键盘钩子监听,在回调里修改录制状态。这样录制脚本全程不占鼠标,游戏窗口始终持有输入焦点。
录制前的环境准备有一件容易忽略的事:让游戏窗口保持非全屏独占。全屏独占模式下,部分系统版本会延迟低级钩子回调,导致时间戳出现周期性抖动。把游戏设成无边框窗口或窗口模式,录出来的时间轴抖动明显减小。这也为后面回放排掉一个干扰变量。
录完建议立刻做一次人工检查:把时间轴里相邻事件的间隔画出来,看是否均匀。均匀间隔应该呈现稳定的波动,如果某个事件明显孤立悬空,多半是手滑了,这个节点要重新录。这一步是黑匣子时代最便宜的后悔药,花一分钟看间隔总比重打一小时关卡强。
4. 按时间戳击键:回放模拟与相位校准
4.1 用 SendInput 实现毫秒级鼠标模拟
回放时按下和释放要通过系统输入层注入。老一代脚本用 mouse_event 或 keybd_event,存在被部分程序忽略的历史问题。更可靠的是 SendInput,它把事件投递到系统输入队列,路径和真实硬件输入更接近。SendInput 一次调用对应一个输入事件,完整定义略长,但值得一步到位写对:
import ctypes ULONG_PTR = ctypes.POINTER(ctypes.c_ulong) class MOUSEINPUT(ctypes.Structure): _fields_ = [ ("dx", ctypes.c_long), ("dy", ctypes.c_long), ("mouseData", ctypes.c_ulong), ("dwFlags", ctypes.c_ulong), ("time", ctypes.c_ulong), ("dwExtraInfo", ULONG_PTR), ] class KEYBDINPUT(ctypes.Structure): _fields_ = [ ("wVk", ctypes.c_ushort), ("wScan", ctypes.c_ushort), ("dwFlags", ctypes.c_ulong), ("time", ctypes.c_ulong), ("dwExtraInfo", ULONG_PTR), ] class HARDWAREINPUT(ctypes.Structure): _fields_ = [ ("uMsg", ctypes.c_ulong), ("wParamL", ctypes.c_ushort), ("wParamH", ctypes.c_ushort), ] class INPUTUNION(ctypes.Union): _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT), ("hi", HARDWAREINPUT)] class INPUT(ctypes.Structure): _fields_ = [("type", ctypes.c_ulong), ("union", INPUTUNION)] def send_mouse(flags): inp = INPUT(type=0, union=INPUTUNION(mi=MOUSEINPUT(0, 0, 0, flags, 0, None))) return ctypes.windll.user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT))这里有几个关键点。第一,type=0 表示事件类型是鼠标。第二,dx、dy 传 0 且 dwFlags 不包含绝对坐标标志时,等效于在当前鼠标位置触发按键。第三,dwExtraInfo 放 None 即可,不需要伪造来源签名。第四,SendInput 的返回值表示成功注入的事件数,返回 0 说明注入被拦截,通常是权限问题而不是代码问题,后面避坑章节会展开。
4.2 回放循环:基于轮询而不是 sleep 堆叠
回放模块的时间调度很容易写歪。新手常见的写法是每次 sleep 到下一个事件的时间点,但 sleep 在 Windows 上的实际误差可达 10 毫秒以上,几十个事件之后误差累计,整个宏就漂了。正确的做法是维护一个单调时钟,每 1 毫秒轮询一次当前时间,找出所有该触发而还没触发的事件,一次性执行:
import time def play_timeline(events, offset_ms=0): clock_ms = 0 index = 0 last_down = {} while index < len(events): now_ms = int(time.perf_counter() * 1000) clock_ms = now_ms + offset_ms while index < len(events) and events[index]["down_ms"] <= clock_ms: ev = events[index] send_mouse(2) # MOUSEEVENTF_LEFTDOWN last_down[ev["key"]] = ev index += 1 for key, ev in last_down.items(): if ev["up_ms"] <= clock_ms: send_mouse(4) # MOUSEEVENTF_LEFTUP del last_down[key] time.sleep(0.001)这个循环的精髓在于把"时间到没到"的判断和"事件队列"分离。内层 while 负责把到期的按下事件全部注入,第二个 for 负责清掉到期的释放事件。有两个直接好处:一是 events 连续密集时不会因为 sleep 误差丢事件,二是 sleep(0.001) 让出 CPU,又不影响毫秒级粒度。
参数说明:offset_ms 是全局偏移量,正数表示提前触发(因为按 now_ms + offset_ms 计算),负数表示延后。不要在循环里动态改 offset,那只会把节奏越修越乱。time.sleep(0.001) 在 Windows 上实际可能睡 1.5 到 2 毫秒,但轮询方式不会累计错误,因为每次判断都回到同一个时间基准。
4.3 偏移量与起步同步:把时间轴对齐到游戏内的拍
有了时间轴和模拟通道,剩下最关键的是对齐起点。游戏启动到第一拍出现之间有一段前奏,这段长度每次运行不完全一致,因为启动加载和音频初始化的时间会变。要让宏每次都能对上拍,需要做一个起步同步:在音频第一拍出现前的一个已知特征点触发游戏键,同时把这个特征点的系统时间作为时间轴起点。
我常用的校准方法是听感校准:先在游戏里打开一段稳定前奏,循环回放前 4 拍,听每次落点是否在拍上。偏早就把 offset 调大 20 毫秒,偏晚就调小 20 毫秒,直到误差小于 10 毫秒。这个方法不够科学,但胜在直观,整套下来最多五分钟。
更稳妥的起步同步是用播放对齐:宏等待玩家按 P 开始,P 键按下瞬间作为 0 点,然后前奏段自动对齐到录制的起始偏移。这样既避开了游戏启动的不确定性,又保留了精确的绝对时间。配合轮询循环,起步后的每一拍都按时间轴原样执行,不会再漂移。
5. 避坑与常见问题:回放进度的四道坎
5.1 第一道坎:回放到一半整体慢半拍
现象:宏启动后前 10 秒一切正常,第 12 秒起明显滞后,而且滞后量随时间线性增大,越到后面越离谱。
原因排查后发现:录制时间戳基于 perf_counter,回放调度也基于 perf_counter,但启动对齐点用了 time.time。time.time 在 Windows 上精度不足,且可能受系统校时影响小步跳动,导致启动基准与后续时间轴不在同一个时钟线上。看似只差几十毫秒,累积起来就是整段慢半拍。
解决:全局统一使用 time.perf_counter,启动对齐点也用它换算,不要混用两个时钟。另外确认 SendInput 调用没有超时阻塞,极端情况下注入失败会吞掉一个事件,表现也像是滞后,但实际是丢事件,需要用返回值日志区分。
5.2 第二道坎:按键注入被目标窗口忽略
现象:宏运行后,任务管理器能看到进程在动,但游戏完全没有反应。SendInput 返回值是 0,或者返回 1 但游戏窗口不接收。
原因:游戏以管理员权限运行,而宏进程是普通权限。系统会拦截低权限进程向高权限窗口注入输入,反过来则不会。另一个原因是全屏独占模式下,输入焦点不在常规窗口,SendInput 的事件落到了宏自己的进程上。
解决:把宏放到和游戏同一权限等级。最省心的是用管理员身份运行命令行,再启动回放脚本。全屏问题参照前面 3.3 节,把游戏切到无边框窗口模式。测试时可以先用记事本做注入验证,记事本能收到事件说明模拟链路本身没问题,问题出在目标窗口环境。
5.3 第三道坎:录制时间轴里混进噪声点击
现象:回放时星球偶尔多拐一次,位置每次回放都不同,但翻车点总集中在同几个时间戳附近。单独看时间轴文件,这些位置的事件时长特别短。
原因:录制时手在节拍之间轻触了鼠标键,产生 8 到 15 毫秒的短脉冲。真人玩时根本感觉不到,但录制是忠实记录,这种噪声被原样保存进时间轴。
解决:在 3.2 节最小脉冲间隔过滤的基础上,加一个后处理脚本:遍历时间轴,删除 up_ms 减 down_ms 小于 25 毫秒、且与前后事件间隔都小于 45 毫秒的孤立事件。注意只删孤立的短脉冲,不要删正常点按,正常点按通常与前一个事件间隔大于 60 毫秒。处理后重新绘图检查一遍,确认删掉的都是孤立噪声。
5.4 第四道坎:高 DPI 屏上鼠标落点错位
现象:换到高分屏后,同一时间轴回放结果和低分屏完全不同,方向拐得诡异,有时候完全反向。
原因:SendInput 的 dx、dy 以像素为单位,系统在做 DPI 缩放时会把逻辑坐标和物理坐标换算。宏进程没有声明 DPI 感知时,系统按缩放后的坐标投递,导致映射偏移。录制的坐标和回放坐标不在同一坐标系,表现就是错位。
解决:如果宏不需要移动鼠标,最省事的是放弃鼠标,改成键盘映射。在游戏设置里把方向操作绑定到一个按键,时间轴记录该按键的 down_ms 和 up_ms,SendInput 只发键盘事件,完全不涉及坐标缩放,这个变量直接消掉。如果必须用鼠标坐标,就在宏启动时调用 SetProcessDpiAwareness,把进程声明为感知 DPI,再重新录制时间轴。
6. 进阶:用节拍检测自动生成时间轴,甩开手打天赋
6.1 librosa 提取节拍:代码与参数
当目标关卡谱面规整、节奏稳定,不想一关录一次时,可以让音频检测来兜底。常用开源库是 librosa,它把音频解码、节拍追踪都封装好了。最小代码如下:
import librosa y, sr = librosa.load("track.ogg", sr=22050) tempo, beats = librosa.beat.beat_track(y=y, sr=sr) beat_ms = librosa.frames_to_time(beats, sr=sr) * 1000这里三个参数值得说明。第一,sr 改用 22050,节拍检测用不到 44.1k 的高频细节,降采样后计算量少一半,低音鼓点的包络更清晰。第二,tempo 返回的是全局 BPM 估计,变速关卡里这个值不可信,需要做滑动窗口重估。第三,frames_to_time 返回拍点对应的时刻,单位是秒,乘 1000 才能与录制时间轴对齐。
6.2 从节拍到点击序列:模式折叠
拍点不能直接当点击用。谱面里经常有两拍一压、空拍停顿、连击三连,把所有拍都发一遍,哪怕每拍都准,动作也会多出一堆。常见做法是模式折叠:把检测到的拍点当作候选序列,用前几个小节或一段已录制引子里的实际点击模式做模板,循环对齐到后面的拍点。比如模板是"点、点、空、点",就按这个模板在拍点上取子序列,折叠掉多余的空拍。
模板取少了会漏变奏段落,取多了又失去自动化意义。经验值是模板长度取一个乐句,通常是 8 拍,对齐时用互相关做偏移匹配,选匹配分数最高的对齐位置作为基准。这一步完成后仍然建议抽查中间段落,变速密集的地方手动补几个事件,比全自动结果可靠得多。
6.3 验证回放可靠度与收尾习惯
最后是验证环节。我会设计一个无头验证脚本:不启动游戏,只把回放时注入的输入事件重新录制一遍,比较原时间轴与回放事件的时间差。差值小于 3 毫秒说明模拟链路稳定,大于 10 毫秒就需要回查调度循环。这个验证只要 30 秒,却能把回放问题从"音游玄学"变成可复现的工程指标。
不管用录制还是检测路线,我都保持一个习惯:每个关卡的时间轴单独存一份,把 offset_ms、录制设备环境、窗口模式、采样率写进同一个 JSON 文件头。这样每次换电脑,只看文件头的环境说明就能判断该改什么,而不是重新录一遍。这是做了多套输入重放工具后沉淀下来的工程习惯,也是这类自动播放宏最值得做的一件事。希望帮到你。
本文还有配套的精品资源,点击获取