承接 Part 9。上一章,我们给路线录制器添加了重复回放功能。这一章,我们进一步观察回放过程:计划什么时候发送按键,脚本实际什么时候准备发送,为什么两者会逐渐偏离,以及为什么不能为了追赶时间而牺牲路线。
一、Part 9 留下的问题
Part 9 的回放核心逻辑是这样的:
PreviousEventTime := 0
for Event in RouteEvents {
Interval := Event.Time - PreviousEventTime
Sleep(Interval)
SendEvent("{" Event.ScanCode " down}")
PreviousEventTime := Event.Time
}
它每次只等"相邻两个事件之间的时间差"。看起来没问题,但实测日志会变成这样:
事件 1: DelayMs = 0
事件 2: DelayMs = 16
事件 3: DelayMs = 31
事件 4: DelayMs = 47
...
事件 54: DelayMs = 1000
每轮末尾晚约 1 秒,跑 25 轮就是 25 秒的累计偏差。Part 9 的文章里我们把这归因于"逐事件累加 Sleep 误差"。Part 10 要回答三个问题:
- 这个误差到底从哪来?
- 为什么"等到绝对时刻"能消除它?
- AHK v2 里怎么把"等到绝对时刻"写得准、写得稳、还能随时按 F12 停?
二、误差从哪来:Sleep 不是精确的
2.1 Windows 的计时器精度
Windows 默认的系统计时器精度是 15.6ms(1/64 秒)。你调用 Sleep(16),实际可能睡 15ms,也可能睡 31ms,取决于当前 tick 落在哪里。
Sleep(16) ; 实际耗时可能是 15ms 或 31ms
即使你调用 timeBeginPeriod(1) 把精度提到 1ms,Sleep(1) 实际也常是 1~2ms,而且有调度开销。
你可以把它理解成:你让程序“休息 16 毫秒”,但系统不一定能准时叫醒它。
想象你在等公交车:
- 你希望 16 分钟后出发。
- 但公交车不是随到随走,而是隔一段时间才来一班。
- 到了你想出发的时间,如果车还没来,你就得继续等。
2.2 误差会累积
相对调度的每个间隔误差,会叠加到下一个间隔上:
计划间隔: 100 100 100 100
实际间隔: 115 115 115 115
实际时刻: 115 230 345 460 ← 误差线性增长
54 个事件,每个多约 18ms,累积起来正好约 1 秒。这就是日志里 DelayMs 从 0 涨到 1000 的原因。
2.3 为什么不能简单"少睡一点"
有人会想:那我 Sleep(Interval - 10) 不就行了?
不行。因为误差不是固定 10ms,它随系统负载、tick 位置波动。你减 10ms,可能这次刚好,下次又多了 5ms,再下次少了 3ms。开环补偿治标不治本。
正确的做法是闭环:每个事件都对准一个绝对时刻,晚了就少等,早了就多等。
三、绝对时间调度:每个事件对准自己的时刻
3.1 思路
路线里每个事件记录的是"相对于路线起点的绝对时间":
事件 1: 1000ms
事件 2: 1688ms
事件 3: 5813ms
回放时,先记下本轮开始的时刻 RoundStartMs,然后对每个事件计算它的绝对目标时刻:
TargetMs = RoundStartMs + Event.Time
然后一直等到 TargetMs,而不是等"和上一个事件的差值"。
3.2 误差不再累积
计划时刻: 100 200 300 400
实际时刻: 115 200 315 400
误差: +15 0 +15 0
第 1 个事件晚了 15ms,第 2 个事件发现"已经 115ms 了,目标 200ms,再等 85ms 就行",于是准时。第 3 个又晚 15ms,第 4 个又追回来。误差有界,不增长。
3.3 代码
PlayOneRouteRound() {
RoundStartMs := QpcMs()
for EventIndex, Event in RouteEvents {
PlannedTime := Round(Event.Time * PlaybackSpeed)
TargetMs := RoundStartMs + PlannedTime
; 等到绝对时刻
PreciseSleepUntilMs(TargetMs)
SendEvent("{" Event.ScanCode " down}")
}
}
注意:RoundStartMs 只在每轮开始时取一次,轮内所有事件都基于它计算目标时刻。这样某个事件晚了,不会影响后面事件的目标时刻。
四、高精度计时:为什么不用 A_TickCount
4.1 A_TickCount 的局限
A_TickCount 返回系统启动以来的毫秒数,够用,但:
- 精度受系统计时器影响,默认 15.6ms;
- 它是整数毫秒,短间隔事件(16ms)的舍入误差占比很大;
- 不适合做"毫秒级对准"。
4.2 QueryPerformanceCounter
Windows 提供高精度计时器 QueryPerformanceCounter(QPC),精度通常是微秒级。AHK v2 通过 DllCall 调用:
InitQpc() {
global QpcFreq
DllCall("QueryPerformanceFrequency", "Int64*", &QpcFreq)
}
QpcMs() {
global QpcFreq
DllCall("QueryPerformanceCounter", "Int64*", &Count)
return Count / QpcFreq * 1000
}
QueryPerformanceFrequency 返回每秒的计数频率(比如 10,000,000),QueryPerformanceCounter 返回当前计数。两者相除就是秒,乘 1000 就是毫秒。
4.3 为什么先除后乘
return Count / QpcFreq * 1000
而不是:
return Count * 1000 / QpcFreq ; 不推荐
因为 Count 是 64 位整数,乘 1000 可能溢出,或者先转成浮点丢精度。先除后乘,中间结果是浮点,安全。
4.4 初始化要检查失败
DllCall 可能失败,QpcFreq 可能是 0。除以 0 会抛异常,所以在初始化时检查:
if !Freq
throw Error("QueryPerformanceFrequency 返回 0,无法进行高精度计时。")
回放开始前调用 InitQpc(),失败就取消回放并提示,而不是跑到一半崩掉。
五、Sleep 精度:timeBeginPeriod 与最后几毫秒
5.1 临时提高系统计时器精度
DllCall("winmm\timeBeginPeriod", "UInt", 1)
这行告诉 Windows:"接下来一段时间我需要 1ms 精度的计时器。" 调用后,Sleep(1) 实际约 1~2ms,而不是 15.6ms。
用完要还,否则会影响系统功耗和其他程序:
DllCall("winmm\timeEndPeriod", "UInt", 1)
脚本里用 TimePeriodSet 标志记录是否设置过,在 CleanupBeforeExit 里恢复。
5.2 等到目标时刻的写法
PreciseSleepUntilMs(TargetMs) {
global IsRoutePlaying
while IsRoutePlaying {
Remaining := TargetMs - QpcMs()
if Remaining <= 0
return true
if Remaining > 3 {
Sleep(Min(5, Max(1, Floor(Remaining) - 2)))
} else {
Sleep(1)
}
}
return false
}
几个设计点:
为什么大块时间用 Sleep(5) 而不是 Sleep(Remaining)?
因为 Sleep(Remaining) 可能睡过头,醒来发现已经超过目标。每次睡一小段,醒来重新检查剩余时间,更稳。
为什么最后 3ms 用 Sleep(1) 而不是纯忙等?
纯忙等(while QpcMs() < TargetMs)会占满 CPU,AHK 的消息循环停止,F12 热键、窗口焦点检查都无法及时响应。Sleep(1) 在 timeBeginPeriod(1) 下精度足够,又能让出 CPU。
为什么循环条件里有 IsRoutePlaying?
这样 F12 紧急停止时,IsRoutePlaying 变成 false,等待循环立即退出,不会卡在 Sleep 里。
5.3 绝对调度的好处:不追求单次极致精度
因为每个事件都对准绝对时刻,单次晚 10ms 不影响下一个事件。所以 PreciseSleepUntilMs 不需要精确到 1ms 以内,只要"平均误差小、不累积"就够了。这让实现可以兼顾精度和响应性。
六、可中断等待:轮间等待与 F12
6.1 轮间等待不能直接用 Sleep
Part 9 的轮间等待是:
Sleep(PlaybackRepeatIntervalMs)
问题是:Sleep(8000) 期间,F12 按了也没用,脚本要等 8 秒才响应。
6.2 可中断等待
InterruptibleSleep(DurationMs) {
global IsRoutePlaying
if DurationMs <= 0
return IsRoutePlaying
EndTick := A_TickCount + DurationMs
while A_TickCount < EndTick {
if !IsRoutePlaying
return false
Remaining := EndTick - A_TickCount
Sleep(Min(Remaining, 20))
}
return IsRoutePlaying
}
思路:把长等待切成 20ms 的小段,每段醒来检查 IsRoutePlaying。F12 把 IsRoutePlaying 设为 false,下一个 20ms 内就退出。
为什么用 A_TickCount 而不是 QPC?
轮间等待不参与事件计划时间,精度要求低,A_TickCount 足够,而且 InterruptibleSleep 本身会被调用很多次,用更轻量的 A_TickCount 更合适。
6.3 两种等待的分工
| 函数 | 用途 | 计时器 | 精度要求 |
|---|---|---|---|
PreciseSleepUntilMs | 轮内事件对准 | QPC | 高 |
InterruptibleSleep | 轮间等待、倒计时 | A_TickCount | 低 |
这个分工很重要:不是所有等待都需要高精度,轮间 8 秒等待差 5ms 完全无所谓。
七、诊断日志:让误差可见
7.1 三个时间字段
PlannedTime := Round(Event.Time * PlaybackSpeed)
ActualTime := Round(QpcMs() - RoundStartMs)
DelayMs := ActualTime - PlannedTime
PlannedTime:按路线和速度倍率算出的计划相对时刻;ActualTime:实际发送前,相对于本轮开始的时刻;DelayMs:实际减计划,正数表示晚了,负数表示早了。
7.2 日志只写内存,不写磁盘
AppendPlaybackLog(...) {
global PlaybackLogRows
PlaybackLogRows.Push({...})
}
每个事件都写磁盘会显著放大计时误差。正确做法是:事件之间只往数组里 push,回放全部结束后一次性写 CSV。
7.3 日志能告诉你什么
DelayMs随事件序号增长 → 相对调度,误差累积;DelayMs在固定范围抖动 → 绝对调度,误差有界;GameActive = 0→ 发送时窗口失去焦点,回放被中断;- 某个事件
DelayMs异常大 → 那个位置系统负载高,或 Sleep 被拉长。
八、错误处理:不让一次异常毁掉整轮日志
8.1 try / catch / finally
回放主循环用 try ... catch ... finally 包住:
try {
Loop PlaybackRepeatCount {
...
if !PlayOneRouteRound()
break
...
}
} catch as Err {
MsgBox("轮次:" CurrentPlaybackRound "`n错误:" Err.Message ...)
} finally {
IsRoutePlaying := false
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
try SavePlaybackLogToCsv()
}
finally 是关键:无论正常结束、F12 中断、失焦、还是抛异常,都执行清理和日志保存。这样即使第 13 轮出错,前 12 轮的诊断日志还在。
8.2 单事件级别的错误定位
PlayOneRouteRound 里每个事件包了 try/catch:
try {
...
} catch as Err {
throw Error(
"第 " CurrentPlaybackRound " 轮第 " EventIndex " 个事件出错。"
. "`n按键:" Event.Key " / 动作:" Event.Action
. "`n原始错误:" Err.Message
)
}
这样报错时能看到"第几轮、第几个事件、哪个键",而不是只有一行"DllCall 失败"。
8.3 资源清理
CleanupBeforeExit 在脚本退出时执行:
if TimePeriodSet {
try DllCall("winmm\timeEndPeriod", "UInt", 1)
TimePeriodSet := false
}
恢复系统计时器精度,释放按键,保存日志。自己设置的状态,自己负责恢复。
九、完整结构回顾
把 Part 10 新增的部分放回整个脚本,结构是这样的:
全局配置
├─ PlaybackRepeatIntervalMs = 8000
├─ QpcFreq, TimePeriodSet
└─ ...
基础设置
├─ SetKeyDelay
├─ OnExit(CleanupBeforeExit)
└─ InitializeKeyboardRecorder()
高精度计时
├─ InitQpc() ; 初始化 QPC + timeBeginPeriod(1)
├─ QpcMs() ; 返回高精度毫秒
└─ PreciseSleepUntilMs(TargetMs) ; 等到绝对时刻
录制(Part 9 不变)
├─ StartRouteRecording
├─ RecordAnyKeyboardEvent
└─ StopRouteRecording
回放(Part 10 改动)
├─ PlayRecordedRoute ; 轮次循环 + 轮间等待
├─ PlayOneRouteRound ; 绝对时间调度
├─ InterruptibleSleep ; 轮间可中断等待
└─ ReplayKeyboardEvent
诊断日志
├─ InitializePlaybackLog
├─ AppendPlaybackLog
└─ SavePlaybackLogToCsv
清理
├─ EmergencyStop ; F12
└─ CleanupBeforeExit ; 退出时恢复计时器精度
十、小结
这一篇讲了什么
- Sleep 不精确,相对调度的误差会线性累积;
- 绝对时间调度让每个事件对准自己的目标时刻,误差有界;
- QPC 提供微秒级计时,替代
A_TickCount; - timeBeginPeriod(1) 临时提高系统计时器精度,用完要
timeEndPeriod(1); - PreciseSleepUntilMs 大块 Sleep + 最后 Sleep(1),兼顾精度和响应性;
- InterruptibleSleep 把长等待切段,保证 F12 能及时停;
- 诊断日志只写内存,结束后一次性落盘;
- try/catch/finally 保证异常时也保存日志、释放按键、恢复计时器精度。
下一篇预告
Part 11 可以往两个方向走:
- 条件等待与状态检测:用
PixelGetColor/PixelSearch判断游戏是否加载完成、是否回到起点,把"死等 8 秒"变成"加载完就走"; - 多路线与流程编排:把多个 CSV 路线串起来,支持"跑图 → 偷窃 → 回城"这样的复合流程。
#Requires AutoHotkey v2.0
#SingleInstance Force
#Warn
; =============================================================================
; 八方旅人路线录制器 - AutoHotkey v2
; 功能:
; 1. 全键盘 Down/Up 录制
; 2. 路线 CSV 保存与加载
; 3. 多轮重复回放
; 4. 绝对时间调度回放,避免逐事件 Sleep 误差累积
; 5. 输出计划时间、实际发送时间、延迟、焦点状态等回放诊断日志
; 6. F12 紧急停止并释放按键
;
; 热键:
; F5 设置重复回放次数
; F6 查看脚本状态
; F7 捕获当前活动窗口作为游戏窗口
; F8 开始录制
; F9 停止录制并保存
; F10 回放路线
; F11 从 CSV 重新加载路线
; F12 紧急停止并释放按键
; =============================================================================
; =============================== 全局配置 ====================================
global GameHwnd := 0
global IsRouteRecording := false
global IsRoutePlaying := false
global RouteStartTick := 0
global RouteEvents := []
global RecordedKeyStates := Map()
global RegisteredKeyboardKeys := []
global RouteFile := A_ScriptDir "\octopath_route.csv"
; 回放诊断日志。每次按 F10 启动新的完整回放任务时覆盖旧日志。
global PlaybackLogFile := A_ScriptDir "\octopath_playback_log.csv"
global PlaybackLogRows := []
; 回放速度倍率:1.00 原速;0.90 更快;1.10 更慢。
global PlaybackSpeed := 1.00
; 重复回放次数。可以直接修改,也可以运行时按 F5 设置。
global PlaybackRepeatCount := 1
; 每轮路线结束后,到下一轮开始前的等待时间,单位毫秒。
global PlaybackRepeatIntervalMs := 8000
; 回放前倒计时秒数。仅在整个回放任务开始前执行一次。
global PlaybackCountdownSeconds := 3
; 当前正在执行第几轮,仅用于状态显示和日志记录。
global CurrentPlaybackRound := 0
; 是否实时显示最新录制事件。
global ShowRecordingEventToolTip := true
; 只在已捕获游戏窗口位于前台时记录。
global RecordOnlyWhenGameActive := true
global RecordingWasPausedForFocus := false
global RecordingPauseStartedTick := 0
; 高精度计时相关。
global QpcFreq := 0
global TimePeriodSet := false
; F5-F12 是脚本控制键,不写入路线。
global ExcludedRecordingKeys := Map(
"F5", true,
"F6", true,
"F7", true,
"F8", true,
"F9", true,
"F10", true,
"F11", true,
"F12", true
)
; ============================== 基础设置 =====================================
SetKeyDelay(10, 10)
CoordMode("Mouse", "Screen")
CoordMode("Pixel", "Screen")
CoordMode("ToolTip", "Screen")
OnExit(CleanupBeforeExit)
; 自动执行段在第一条热键定义处结束,因此启动初始化必须放在热键之前
InitializeKeyboardRecorder()
ShowTemporaryToolTip(
"路线录制器已启动`n"
. "F7 捕获窗口,F8 开始录制`n"
. "F5 设置重复回放次数",
3200
)
; ============================== 控制热键. =====================================
F5::SetPlaybackRepeatCount()
F6::ShowRecorderStatus()
F7::CaptureGameWindow()
F8::StartRouteRecording()
F9::StopRouteRecording()
F10::PlayRecordedRoute()
F11::LoadRouteFromCsv()
F12::EmergencyStop()
; =============================================================================
; 高精度计时初始化
; 同时把 Windows 系统计时器精度临时提高到 1ms,退出时恢复
; =============================================================================
InitQpc() {
global QpcFreq, TimePeriodSet
if !QpcFreq {
Freq := 0
try {
DllCall("QueryPerformanceFrequency", "Int64*", &Freq)
} catch as Err {
throw Error("QueryPerformanceFrequency 调用失败:" Err.Message)
}
if !Freq
throw Error("QueryPerformanceFrequency 返回 0,无法进行高精度计时。")
QpcFreq := Freq
}
if !TimePeriodSet {
try DllCall("winmm\timeBeginPeriod", "UInt", 1)
TimePeriodSet := true
}
}
; =============================================================================
; 返回高精度毫秒时间.
; 先除后乘,避免 Count * 1000 在 AHK v2 中溢出或丢精度
; =============================================================================
QpcMs() {
global QpcFreq
if !QpcFreq
InitQpc()
Count := 0
try {
DllCall("QueryPerformanceCounter", "Int64*", &Count)
} catch as Err {
throw Error("QueryPerformanceCounter 调用失败:" Err.Message)
}
if !QpcFreq
throw Error("QpcFreq 为 0,无法计算高精度时间。")
return Count / QpcFreq * 1000
}
; =============================================================================
; 等到指定的绝对毫秒时间点.
; 大块时间用 Sleep;最后阶段用 Sleep(1),不再纯忙等,
; 保证 F12 热键和窗口焦点检查能及时响应。
; 因为回放是绝对时间调度,即使某个事件晚了几毫秒,下一个事件会追赶,
; 不会像旧逻辑那样逐事件累积误差。
; =============================================================================
PreciseSleepUntilMs(TargetMs) {
global IsRoutePlaying
while IsRoutePlaying {
if !EnsurePlaybackWindowActive()
return false
Remaining := TargetMs - QpcMs()
if Remaining <= 0
return true
if Remaining > 3 {
Sleep(Min(5, Max(1, Floor(Remaining) - 2)))
} else {
; timeBeginPeriod(1) 下 Sleep(1) 约 1~2ms,
; 比纯忙等更能保持消息循环响应。
Sleep(1)
}
}
return false
}
; =============================================================================
; 设置重复回放次数
; =============================================================================
SetPlaybackRepeatCount() {
global PlaybackRepeatCount, IsRouteRecording, IsRoutePlaying
if IsRouteRecording {
ShowTemporaryToolTip("录制期间不能修改回放次数。", 1800)
return
}
if IsRoutePlaying {
ShowTemporaryToolTip("回放期间不能修改回放次数。", 1800)
return
}
Result := InputBox(
"请输入路线重复回放次数。`n范围:1 至 999。`n`n"
. "当前设置:" PlaybackRepeatCount,
"设置重复回放次数",
"w360 h175",
PlaybackRepeatCount
)
if Result.Result != "OK"
return
InputValue := Trim(Result.Value)
if !IsInteger(InputValue) {
MsgBox("请输入整数,例如 1、3 或 10。", "输入无效", "Icon!")
return
}
NewCount := Integer(InputValue)
if NewCount < 1 || NewCount > 999 {
MsgBox("重复次数必须在 1 至 999 之间。", "输入无效", "Icon!")
return
}
PlaybackRepeatCount := NewCount
ShowTemporaryToolTip("重复回放次数已设置为:" PlaybackRepeatCount, 2200)
}
; =============================================================================
; 注册大部分键盘扫描码的 Down/Up 监听
; =============================================================================
InitializeKeyboardRecorder() {
global RegisteredKeyboardKeys
RegisteredKeyboardKeys := []
Loop 0x1FF {
ScanCode := Format("sc{:03X}", A_Index)
KeyName := GetKeyName(ScanCode)
if KeyName = "" || IsExcludedRecordingKey(KeyName)
continue
try {
; $ 防止脚本自己的 SendEvent 再触发录制。
; ~ 不拦截真实按键,按键仍传给游戏。
; * 无论修饰键状态如何都能触发。
Hotkey(
"$~*" ScanCode,
RecordAnyKeyboardEvent.Bind(ScanCode, KeyName, "Down"),
"On"
)
Hotkey(
"$~*" ScanCode " Up",
RecordAnyKeyboardEvent.Bind(ScanCode, KeyName, "Up"),
"On"
)
RegisteredKeyboardKeys.Push({
ScanCode: ScanCode,
KeyName: KeyName
})
} catch {
; 某些扫描码无法注册,忽略即可。
}
}
}
; =============================================================================
; 捕获当前活动窗口
; =============================================================================
CaptureGameWindow() {
global GameHwnd, IsRouteRecording, IsRoutePlaying
if IsRouteRecording || IsRoutePlaying {
ShowTemporaryToolTip("录制或回放期间不能重新捕获窗口。", 1800)
return
}
ActiveHwnd := WinExist("A")
if !ActiveHwnd {
MsgBox("没有找到当前活动窗口。")
return
}
try {
WindowTitle := WinGetTitle("ahk_id " ActiveHwnd)
ProcessName := WinGetProcessName("ahk_id " ActiveHwnd)
} catch as Err {
MsgBox("无法读取窗口信息。`n`n错误:" Err.Message)
return
}
GameHwnd := ActiveHwnd
ToolTip(
"游戏窗口捕获成功`n"
. "标题:" WindowTitle "`n"
. "进程:" ProcessName "`n"
. "将角色放到固定起点后按 F8",
20,
20
)
SetTimer(HideToolTip, -4000)
}
; =============================================================================
; 开始录制
; =============================================================================
StartRouteRecording() {
global GameHwnd, IsRouteRecording, IsRoutePlaying
global RouteStartTick, RouteEvents, RecordedKeyStates
global RecordingWasPausedForFocus, RecordingPauseStartedTick
if IsRoutePlaying {
ShowTemporaryToolTip("当前正在回放,不能开始录制。", 1800)
return
}
if IsRouteRecording {
ShowTemporaryToolTip("路线录制已经开始。", 1500)
return
}
if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
GameHwnd := 0
MsgBox("游戏窗口无效。`n`n请激活游戏窗口后按 F7。")
return
}
if !WinActive("ahk_id " GameHwnd) {
MsgBox("游戏窗口当前不在前台。`n`n请先点击游戏窗口,再按 F8。")
return
}
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
RouteEvents := []
RecordedKeyStates := Map()
RecordingWasPausedForFocus := false
RecordingPauseStartedTick := 0
RouteStartTick := A_TickCount
IsRouteRecording := true
SetTimer(CheckRecordingWindowFocus, 100)
ToolTip(
"全键盘路线录制已开始`n"
. "仅记录游戏窗口中的键盘事件`n"
. "F5-F12 不会写入路线`n"
. "到达目标后按 F9",
20,
20
)
}
; =============================================================================
; 记录一个键盘事件
; =============================================================================
RecordAnyKeyboardEvent(ScanCode, KeyName, Action, *) {
global GameHwnd, IsRouteRecording, IsRoutePlaying
global RouteStartTick, RouteEvents, RecordedKeyStates
global RecordOnlyWhenGameActive, ShowRecordingEventToolTip
if !IsRouteRecording || IsRoutePlaying
return
if IsExcludedRecordingKey(KeyName)
return
if RecordOnlyWhenGameActive {
if !GameHwnd || !WinActive("ahk_id " GameHwnd)
return
}
ResumeRouteRecordingClock()
if !RecordedKeyStates.Has(ScanCode)
RecordedKeyStates[ScanCode] := false
if Action = "Down" {
; 忽略长按产生的重复 Down。
if RecordedKeyStates[ScanCode]
return
RecordedKeyStates[ScanCode] := true
} else {
; 忽略没有对应 Down 的孤立 Up。
if !RecordedKeyStates[ScanCode]
return
RecordedKeyStates[ScanCode] := false
}
RelativeTime := A_TickCount - RouteStartTick
RouteEvents.Push({
Time: RelativeTime,
Key: KeyName,
ScanCode: ScanCode,
Action: Action
})
if ShowRecordingEventToolTip {
ToolTip(
"正在录制游戏路线`n"
. "事件数量:" RouteEvents.Length "`n"
. "按键:" KeyName "`n"
. "动作:" Action "`n"
. "时间:" RelativeTime " ms`n"
. "按 F9 停止",
20,
20
)
}
}
; =============================================================================
; 录制期间监控窗口焦点
; =============================================================================
CheckRecordingWindowFocus() {
global GameHwnd, IsRouteRecording, RecordingWasPausedForFocus
global RouteStartTick, RecordingPauseStartedTick
if !IsRouteRecording {
SetTimer(CheckRecordingWindowFocus, 0)
return
}
if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
StopRouteRecordingBecauseWindowLost()
return
}
if !WinActive("ahk_id " GameHwnd) {
if !RecordingWasPausedForFocus {
RecordingPauseStartedTick := A_TickCount
RecordingWasPausedForFocus := true
CloseHeldKeysAtTime(RecordingPauseStartedTick - RouteStartTick)
}
ToolTip(
"录制已暂停`n"
. "游戏窗口当前不在前台`n"
. "重新激活游戏后继续记录",
20,
20
)
return
}
if RecordingWasPausedForFocus {
; 将失焦时长从路线时钟中扣除,恢复后路线时间继续从暂停点累计
ResumeRouteRecordingClock()
ToolTip("游戏窗口已恢复前台`n路线录制继续", 20, 20)
}
}
; =============================================================================
; 恢复路线录制时钟
; 比如暂停5000ms,恢复后路线起始时间需要增加这段暂停的时间,从而获得更精准的相对时间
; =============================================================================
ResumeRouteRecordingClock() {
global RouteStartTick, RecordingPauseStartedTick, RecordingWasPausedForFocus
if !RecordingWasPausedForFocus
return
RouteStartTick += A_TickCount - RecordingPauseStartedTick
RecordingPauseStartedTick := 0
RecordingWasPausedForFocus := false
}
; =============================================================================
; 当找不到游戏窗口时,停止录制并保存
; =============================================================================
StopRouteRecordingBecauseWindowLost() {
global IsRouteRecording
if !IsRouteRecording
return
IsRouteRecording := false
SetTimer(CheckRecordingWindowFocus, 0)
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
MsgBox("游戏窗口已关闭或句柄失效。`n本次录制已停止,未自动保存。")
}
; =============================================================================
; 停止录制并保存
; =============================================================================
StopRouteRecording() {
global IsRouteRecording, RouteEvents, RouteStartTick, RouteFile
global RecordingWasPausedForFocus, RecordingPauseStartedTick
if !IsRouteRecording {
ShowTemporaryToolTip("当前没有正在进行的路线录制。", 1800)
return
}
StopTime := RecordingWasPausedForFocus
? RecordingPauseStartedTick - RouteStartTick
: A_TickCount - RouteStartTick
IsRouteRecording := false
RecordingWasPausedForFocus := false
RecordingPauseStartedTick := 0
SetTimer(CheckRecordingWindowFocus, 0)
CloseHeldKeysAtTime(StopTime)
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
if RouteEvents.Length = 0 {
ShowTemporaryToolTip("没有录制到游戏窗口中的键盘事件。", 2200)
return
}
SortRouteEventsByTime()
try SaveRouteToCsv()
catch as Err {
MsgBox("路线保存失败。`n`n错误:" Err.Message)
return
}
ToolTip(
"路线录制完成并已保存`n"
. "事件数量:" RouteEvents.Length "`n"
. "总时长:" Round(GetRouteDuration() / 1000, 2) " 秒`n"
. "文件:" RouteFile "`n"
. "按 F5 设置次数,按 F10 回放",
20,
20
)
SetTimer(HideToolTip, -5000)
}
; =============================================================================
; 释放当前按住的所有键.
; =============================================================================
CloseHeldKeysAtCurrentTime() {
global RouteStartTick
CloseHeldKeysAtTime(A_TickCount - RouteStartTick)
}
; =============================================================================
; 释放当前按住的所有键
; =============================================================================
CloseHeldKeysAtTime(EventTime) {
global RouteEvents, RecordedKeyStates
for ScanCode, IsPressed in RecordedKeyStates {
if !IsPressed
continue
KeyName := GetKeyName(ScanCode)
if KeyName = ""
KeyName := ScanCode
RouteEvents.Push({
Time: EventTime,
Key: KeyName,
ScanCode: ScanCode,
Action: "Up"
})
; 补记路线松键时,也发送实际松键,避免按键状态残留。
try SendEvent("{" ScanCode " up}")
RecordedKeyStates[ScanCode] := false
}
}
; =============================================================================
; 按时间稳定排序
; =============================================================================
SortRouteEventsByTime() {
global RouteEvents
Count := RouteEvents.Length
if Count < 2
return
Loop Count - 1 {
SourceIndex := A_Index + 1
CurrentEvent := RouteEvents[SourceIndex]
InsertIndex := SourceIndex - 1
while InsertIndex >= 1
&& RouteEvents[InsertIndex].Time > CurrentEvent.Time {
RouteEvents[InsertIndex + 1] := RouteEvents[InsertIndex]
InsertIndex -= 1
}
RouteEvents[InsertIndex + 1] := CurrentEvent
}
}
; =============================================================================
; 保存和加载路线 CSV.
; =============================================================================
SaveRouteToCsv() {
global RouteEvents, RouteFile
CsvContent := "TimeMs,ScanCode,Action,KeyName`n"
for Event in RouteEvents {
CsvContent .= Event.Time ","
. EscapeCsvField(Event.ScanCode) ","
. EscapeCsvField(Event.Action) ","
. EscapeCsvField(Event.Key) "`n"
}
; 先完整写入同目录临时文件,再替换正式路线,避免写入失败时丢失旧文件。
TempRouteFile := RouteFile ".tmp"
try {
if FileExist(TempRouteFile)
FileDelete(TempRouteFile)
FileAppend(CsvContent, TempRouteFile, "UTF-8")
FileMove(TempRouteFile, RouteFile, true)
} catch as Err {
if FileExist(TempRouteFile)
try FileDelete(TempRouteFile)
throw Err
}
}
; =============================================================================
; 加载路线 CSV
; =============================================================================
LoadRouteFromCsv() {
global RouteEvents, RouteFile, IsRouteRecording, IsRoutePlaying
global PlaybackRepeatCount
if IsRouteRecording || IsRoutePlaying {
ShowTemporaryToolTip("录制或回放期间不能加载路线。", 1800)
return false
}
if !FileExist(RouteFile) {
MsgBox("路线文件不存在:`n" RouteFile)
return false
}
try FileContent := FileRead(RouteFile, "UTF-8")
catch as Err {
MsgBox("路线文件读取失败。`n`n错误:" Err.Message)
return false
}
LoadedEvents := []
Lines := StrSplit(FileContent, "`n", "`r")
for LineNumber, Line in Lines {
if LineNumber = 1 || Trim(Line) = ""
continue
Columns := ParseCsvLine(Line)
if Columns.Length < 4
continue
TimeText := Trim(Columns[1])
ScanCode := Trim(Columns[2])
Action := Trim(Columns[3])
KeyName := Trim(Columns[4])
if !IsInteger(TimeText)
continue
if ScanCode = ""
continue
if Action != "Down" && Action != "Up"
continue
LoadedEvents.Push({
Time: Integer(TimeText),
ScanCode: ScanCode,
Action: Action,
Key: KeyName
})
}
if LoadedEvents.Length = 0 {
MsgBox("路线文件中没有有效的键盘事件。")
return false
}
RouteEvents := LoadedEvents
SortRouteEventsByTime()
ToolTip(
"路线读取成功`n"
. "事件数量:" RouteEvents.Length "`n"
. "总时长:" Round(GetRouteDuration() / 1000, 2) " 秒`n"
. "重复次数:" PlaybackRepeatCount,
20,
20
)
SetTimer(HideToolTip, -3500)
return true
}
; =============================================================================
; 用来把一个字段转换成合法的 CSV 格式,避免内容里的逗号或引号被误认为字段分隔符
; =============================================================================
EscapeCsvField(Value) {
Value := String(Value)
if InStr(Value, '"')
Value := StrReplace(Value, '"', '""')
if InStr(Value, ",")
|| InStr(Value, '"')
|| InStr(Value, "`n")
|| InStr(Value, "`r") {
return '"' Value '"'
}
return Value
}
; =============================================================================
; 解析CSV每一行
; =============================================================================
ParseCsvLine(Line) {
Fields := []
CurrentField := ""
InsideQuotes := false
Index := 1
LineLength := StrLen(Line)
while Index <= LineLength {
Character := SubStr(Line, Index, 1)
if Character = '"' {
if InsideQuotes
&& Index < LineLength
&& SubStr(Line, Index + 1, 1) = '"' {
CurrentField .= '"'
Index += 2
continue
}
InsideQuotes := !InsideQuotes
Index += 1
continue
}
if Character = "," && !InsideQuotes {
Fields.Push(CurrentField)
CurrentField := ""
Index += 1
continue
}
CurrentField .= Character
Index += 1
}
Fields.Push(CurrentField)
return Fields
}
; =============================================================================
; 回放诊断日志
; =============================================================================
InitializePlaybackLog() {
global PlaybackLogRows
PlaybackLogRows := []
}
; =============================================================================
; 追加回放诊断日志
; =============================================================================
AppendPlaybackLog(
RoundNumber,
EventIndex,
PlannedTime,
ActualTime,
DelayMs,
KeyName,
Action,
GameActive
) {
global PlaybackLogRows
; 只写入内存,不在事件之间访问磁盘,避免日志本身造成明显计时干扰。
PlaybackLogRows.Push({
Round: RoundNumber,
EventIndex: EventIndex,
PlannedTime: PlannedTime,
ActualTime: ActualTime,
DelayMs: DelayMs,
Key: KeyName,
Action: Action,
GameActive: GameActive
})
}
; =============================================================================
; 保存回放诊断日志
; =============================================================================
SavePlaybackLogToCsv() {
global PlaybackLogRows, PlaybackLogFile
CsvContent := "Round,EventIndex,PlannedTime,ActualTime,DelayMs,Key,Action,GameActive`n"
for Row in PlaybackLogRows {
CsvContent .= Row.Round ","
. Row.EventIndex ","
. Row.PlannedTime ","
. Row.ActualTime ","
. Row.DelayMs ","
. EscapeCsvField(Row.Key) ","
. EscapeCsvField(Row.Action) ","
. Row.GameActive "`n"
}
if FileExist(PlaybackLogFile)
FileDelete(PlaybackLogFile)
FileAppend(CsvContent, PlaybackLogFile, "UTF-8")
}
; =============================================================================
; 重复回放路线
; =============================================================================
PlayRecordedRoute() {
global GameHwnd, IsRouteRecording, IsRoutePlaying
global RouteEvents, PlaybackSpeed, PlaybackCountdownSeconds
global PlaybackRepeatCount, PlaybackRepeatIntervalMs, CurrentPlaybackRound
global PlaybackLogFile
if IsRouteRecording {
ShowTemporaryToolTip("请先按 F9 停止录制。", 1800)
return
}
if IsRoutePlaying {
ShowTemporaryToolTip("路线已经在回放。", 1600)
return
}
if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
MsgBox("游戏窗口无效。`n请重新激活游戏窗口并按 F7 捕获。")
return
}
if RouteEvents.Length = 0 {
if !LoadRouteFromCsv()
return
}
if PlaybackRepeatCount < 1 {
MsgBox("重复回放次数无效,请按 F5 重新设置。")
return
}
try WinActivate("ahk_id " GameHwnd)
catch as Err {
MsgBox("无法激活游戏窗口。`n`n错误:" Err.Message)
return
}
if !WinWaitActive("ahk_id " GameHwnd, , 2) {
MsgBox("游戏窗口未能获得焦点,回放已取消。")
return
}
; 回放开始前初始化高精度计时,并把系统计时器精度临时提高到 1ms。
try InitQpc()
catch as Err {
MsgBox("高精度计时初始化失败,回放已取消。`n`n错误:" Err.Message)
return
}
InitializePlaybackLog()
IsRoutePlaying := true
CurrentPlaybackRound := 0
CompletedRounds := 0
PlaybackErrorMessage := ""
try {
; 初始化回放环境
SetKeyDelay(-1, -1)
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
RemainingSeconds := PlaybackCountdownSeconds
while RemainingSeconds > 0 {
ToolTip(
RemainingSeconds " 秒后开始回放`n"
. "计划重复:" PlaybackRepeatCount " 次`n"
. "按 F12 可紧急停止",
20,
20
)
if !InterruptibleSleep(1000)
break
RemainingSeconds -= 1
}
if IsRoutePlaying {
Loop PlaybackRepeatCount {
if !IsRoutePlaying
break
CurrentPlaybackRound := A_Index
; 每一轮开始前确认窗口仍存在并处于前台。
if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
IsRoutePlaying := false
MsgBox("游戏窗口已不存在,重复回放已停止。")
break
}
if !WinActive("ahk_id " GameHwnd) {
IsRoutePlaying := false
ToolTip("游戏窗口失去焦点`n重复回放已停止", 20, 20)
break
}
; 保证上一轮没有留下按住状态。
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
ToolTip(
"正在回放路线`n"
. "当前轮次:" CurrentPlaybackRound " / " PlaybackRepeatCount "`n"
. "事件数量:" RouteEvents.Length "`n"
. "速度倍率:" PlaybackSpeed "`n"
. "按 F12 可紧急停止",
20,
20
)
; 执行单轮路线。返回 false 表示被中断或失焦。
if !PlayOneRouteRound()
break
CompletedRounds += 1
; 每轮结束后释放按键。
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
; 最后一轮结束后不再等待。
if CurrentPlaybackRound < PlaybackRepeatCount {
ToolTip(
"第 " CurrentPlaybackRound " 轮完成`n"
. "下一轮:" (CurrentPlaybackRound + 1)
. " / " PlaybackRepeatCount "`n"
. "等待:" PlaybackRepeatIntervalMs " ms`n"
. "按 F12 可停止",
20,
20
)
if !InterruptibleSleep(PlaybackRepeatIntervalMs)
break
}
}
}
} catch as Err {
PlaybackErrorMessage := "路线回放过程中发生错误。`n`n"
. "轮次:" CurrentPlaybackRound "`n"
. "错误:" Err.Message "`n"
. "行号:" Err.Line "`n"
. "函数:" (Err.What != "" ? Err.What : "未知")
} finally {
IsRoutePlaying := false
CurrentPlaybackRound := 0
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
; 无论正常结束、F12 中断、失焦或异常,都尽量保存已收集的日志。
try SavePlaybackLogToCsv()
}
if PlaybackErrorMessage != "" {
MsgBox(PlaybackErrorMessage, "回放错误")
return
}
ShowTemporaryToolTip(
"重复回放结束`n"
. "完整执行轮次:" CompletedRounds " / " PlaybackRepeatCount "`n"
. "诊断日志:" PlaybackLogFile,
3500
)
}
; =============================================================================
; 执行一轮完整路线(绝对时间调度)
;
; 每个事件的目标时间是:
; TargetMs = RoundStartMs + Round(Event.Time * PlaybackSpeed)
; 某个事件晚了不会推迟后续事件的目标时间,因此误差不会累积
; =============================================================================
PlayOneRouteRound() {
global GameHwnd, IsRoutePlaying, RouteEvents, PlaybackSpeed
global CurrentPlaybackRound
RoundStartMs := QpcMs()
for EventIndex, Event in RouteEvents {
if !IsRoutePlaying
return false
if !WinActive("ahk_id " GameHwnd) {
IsRoutePlaying := false
ToolTip("游戏窗口失去焦点`n路线回放已停止", 20, 20)
return false
}
try {
; 该事件在本轮中的计划相对时间(已按速度倍率缩放)。
PlannedTime := Round(Event.Time * PlaybackSpeed)
; 绝对目标时间:本轮开始时间 + 计划相对时间。
TargetMs := RoundStartMs + PlannedTime
; 等到绝对目标时间,而不是等待相邻事件时间差。
if !PreciseSleepUntilMs(TargetMs)
return false
if !IsRoutePlaying
return false
; 等待期间可能切换窗口,发送前再次检查。
GameActive := WinActive("ahk_id " GameHwnd) ? 1 : 0
; ActualTime 是调用 SendEvent 之前,相对于本轮开始的实际时间。
ActualTime := Round(QpcMs() - RoundStartMs)
DelayMs := ActualTime - PlannedTime
if !GameActive {
AppendPlaybackLog(
CurrentPlaybackRound,
EventIndex,
PlannedTime,
ActualTime,
DelayMs,
Event.Key,
Event.Action,
0
)
IsRoutePlaying := false
ToolTip("游戏窗口失去焦点`n路线回放已停止", 20, 20)
return false
}
; 保留原来的发送方式。
ReplayKeyboardEvent(Event)
; 只把日志加入内存,不在事件之间写磁盘,避免额外放大计时误差。
AppendPlaybackLog(
CurrentPlaybackRound,
EventIndex,
PlannedTime,
ActualTime,
DelayMs,
Event.Key,
Event.Action,
1
)
} catch as Err {
throw Error(
"第 " CurrentPlaybackRound " 轮第 " EventIndex " 个事件出错。"
. "`n按键:" Event.Key " / 动作:" Event.Action
. "`n原始错误:" Err.Message,
-1,
Err.Message
)
}
}
return IsRoutePlaying
}
; =============================================================================
; 普通可中断等待,用于轮次间隔,不参与事件计划时间
; =============================================================================
InterruptibleSleep(DurationMs) {
global IsRoutePlaying
if DurationMs <= 0
return IsRoutePlaying
EndTick := A_TickCount + DurationMs
while A_TickCount < EndTick {
if !EnsurePlaybackWindowActive()
return false
Remaining := EndTick - A_TickCount
Sleep(Min(Remaining, 20))
}
return IsRoutePlaying
}
; =============================================================================
; 确保回放窗口处于激活状态,如果不是则停止回放
; =============================================================================
EnsurePlaybackWindowActive() {
global GameHwnd, IsRoutePlaying
if !IsRoutePlaying
return false
if GameHwnd && WinActive("ahk_id " GameHwnd)
return true
IsRoutePlaying := false
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
ToolTip("游戏窗口失去焦点或已关闭`n路线回放已停止", 20, 20)
return false
}
; =============================================================================
; 回放原来的键盘事件
; =============================================================================
ReplayKeyboardEvent(Event) {
global RecordedKeyStates
ReplayKey := Event.ScanCode != "" ? Event.ScanCode : Event.Key
if ReplayKey = ""
return
if Event.Action = "Down" {
SendEvent("{" ReplayKey " down}")
RecordedKeyStates[ReplayKey] := true
} else {
SendEvent("{" ReplayKey " up}")
RecordedKeyStates[ReplayKey] := false
}
}
; =============================================================================
; 释放监听的按键
; =============================================================================
ReleaseAllRecordedKeys() {
global RecordedKeyStates
for ScanCode, IsPressed in RecordedKeyStates {
if IsPressed {
try SendEvent("{" ScanCode " up}")
RecordedKeyStates[ScanCode] := false
}
}
}
; =============================================================================
; 释放通用按键
; =============================================================================
ReleaseCommonGameKeys() {
CommonKeys := [
"Up", "Down", "Left", "Right",
"w", "a", "s", "d",
"Enter", "Escape", "Space", "Tab",
"LShift", "RShift",
"LControl", "RControl",
"LAlt", "RAlt"
]
for KeyName in CommonKeys {
try SendEvent("{" KeyName " up}")
}
}
; =============================================================================
; 紧急停止
; =============================================================================
EmergencyStop() {
global IsRouteRecording, IsRoutePlaying, RecordedKeyStates
global CurrentPlaybackRound
WasRecording := IsRouteRecording
WasPlaying := IsRoutePlaying
StoppedRound := CurrentPlaybackRound
IsRouteRecording := false
IsRoutePlaying := false
CurrentPlaybackRound := 0
SetTimer(CheckRecordingWindowFocus, 0)
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
RecordedKeyStates := Map()
; 如果回放被中止,保存截至中止时已经收集的诊断日志。
if WasPlaying {
try SavePlaybackLogToCsv()
}
Message := "自动化已紧急停止`n所有常用按键已释放"
if WasRecording
Message .= "`n未完成的录制不会自动保存"
else if WasPlaying
Message .= "`n已中止第 " StoppedRound " 轮回放`n诊断日志已保存"
ToolTip(Message, 20, 20)
SetTimer(HideToolTip, -3000)
}
; =============================================================================
; 退出清理
; =============================================================================
CleanupBeforeExit(ExitReason, ExitCode) {
global IsRouteRecording, IsRoutePlaying, CurrentPlaybackRound
global TimePeriodSet
WasPlaying := IsRoutePlaying
IsRouteRecording := false
IsRoutePlaying := false
CurrentPlaybackRound := 0
SetTimer(CheckRecordingWindowFocus, 0)
ReleaseAllRecordedKeys()
ReleaseCommonGameKeys()
if WasPlaying {
try SavePlaybackLogToCsv()
}
; 恢复系统计时器精度。
if TimePeriodSet {
try DllCall("winmm\timeEndPeriod", "UInt", 1)
TimePeriodSet := false
}
}
; =============================================================================
; 状态与辅助函数
; =============================================================================
ShowRecorderStatus() {
global GameHwnd, IsRouteRecording, IsRoutePlaying
global RouteEvents, RouteFile, PlaybackSpeed, RegisteredKeyboardKeys
global PlaybackRepeatCount, PlaybackRepeatIntervalMs, CurrentPlaybackRound
global PlaybackLogFile
if IsRouteRecording
CurrentStatus := "正在录制"
else if IsRoutePlaying
CurrentStatus := "正在回放第 " CurrentPlaybackRound " 轮"
else
CurrentStatus := "空闲"
if GameHwnd && WinExist("ahk_id " GameHwnd) {
try GameTitle := WinGetTitle("ahk_id " GameHwnd)
catch
GameTitle := "无法读取"
WindowStatus := "已捕获:" GameTitle
} else {
WindowStatus := "尚未捕获游戏窗口"
}
DurationText := RouteEvents.Length > 0
? Round(GetRouteDuration() / 1000, 2) " 秒"
: "无路线"
ToolTip(
"路线录制器状态`n"
. "状态:" CurrentStatus "`n"
. "窗口:" WindowStatus "`n"
. "已注册键数:" RegisteredKeyboardKeys.Length "`n"
. "路线事件数:" RouteEvents.Length "`n"
. "单轮时长:" DurationText "`n"
. "回放倍率:" PlaybackSpeed "`n"
. "重复次数:" PlaybackRepeatCount "`n"
. "轮次间隔:" PlaybackRepeatIntervalMs " ms`n"
. "路线文件:" RouteFile "`n"
. "日志文件:" PlaybackLogFile,
20,
20
)
SetTimer(HideToolTip, -6000)
}
; =============================================================================
; 获得总持续时间
; =============================================================================
GetRouteDuration() {
global RouteEvents
return RouteEvents.Length = 0
? 0
: RouteEvents[RouteEvents.Length].Time
}
; =============================================================================
; 排除录制相关快捷键
; =============================================================================
IsExcludedRecordingKey(KeyName) {
global ExcludedRecordingKeys
NormalizedName := GetKeyName(KeyName)
if NormalizedName = ""
NormalizedName := KeyName
return ExcludedRecordingKeys.Has(NormalizedName)
}
; =============================================================================
; 隐藏临时提示
; =============================================================================
ShowTemporaryToolTip(Message, DurationMs := 1500) {
ToolTip(Message, 20, 20)
SetTimer(HideToolTip, -DurationMs)
}
; =============================================================================
; 隐藏提示
; =============================================================================
HideToolTip() {
ToolTip()
}