我平时写脚本有个习惯:先把能自动化的环节全列出来,再一个个去啃,绝不会为了写脚本而写脚本。最近花了两周时间搓了一套“一将成名自走棋”的日常自动化脚本,从最初的鼠标模拟乱点,到后来稳定的状态识别加任务调度,中间踩了不少坑,也把整个思路捋顺了。今天把这套东西从需求到落地完整拆一遍,想自己动手搞游戏自动化,或者对脚本架构感兴趣的朋友,可以拿走当参考。
1. 项目背景与核心需求解析
1.1 为什么需要这套脚本
“一将成名”这个自走棋玩法,日常任务其实相当密集。每天上线要做的固定事情包括签到、领体力、刷日常对局、完成活动任务、收取挂机奖励等等。如果纯手动,一套流程跑下来少说四十分钟,而且大部分时间是盯着屏幕等加载,纯属浪费精力。
我搭这套脚本的出发点很简单:把每天重复的、规则固定的操作交给程序,把时间留出来干别的事。它不是什么外挂,不修改游戏数据,不读取内存,完全走界面自动化路线,就是模拟人手的点击操作。原理上和你用按键精灵写个自动点击脚本没有本质区别,只不过在逻辑架构上做了更工程化的设计。
这个脚本适合谁?大概三类人。第一类是和我一样日常任务繁重、想解放双手的普通玩家。第二类是想入门自动化测试或者脚本开发的人,这套脚本可以当成一个完整的案例来研究。第三类是对图像识别、状态机设计感兴趣的开发爱好者,可以从里面看到一套实际运行中的调度逻辑是怎么组织的。
1.2 自走棋日常任务的特性分析
要把自动化做好,首先得摸清任务本身的规律。我观察了几天,总结出几个关键特征:
- 流程固定:每日任务虽然多,但每项操作的顺序和路径基本不变,这意味着可以用状态机来管理整个执行流程。
- 界面元素稳定:按钮位置、图标样式在版本不变的情况下是固定的,这给图像匹配提供了基础。
- 存在随机等待:加载、战斗、结算等环节的耗时不可预知,必须加入轮询检测机制,而不是死等固定时间。
- 反异常要求高:网络波动、弹窗、活动入口变化都可能打断正常流程,脚本必须有错误恢复能力。
针对这些特征,我确定了几条设计原则:状态机驱动而不是时间线驱动,图像识别优先于坐标硬编码,模块化拆分以便快速调整。
2. 技术方案选型与工具准备
2.1 图像识别方案:模板匹配为何够用
游戏自动化的核心难点是“知道当前在什么界面”。最开始我考虑过直接用坐标写死,毕竟自走棋界面相对固定。但实际跑起来就发现问题了:不同分辨率下坐标会偏,模拟器偶尔弹出对话框会把界面整体顶下去,硬编码玩法必死。
后来换成了图像识别方案。我选的是OpenCV的模板匹配,就是先把关键界面的特征图截下来存好,运行时实时截屏,拿截图去和模板做匹配。匹配度超过阈值就认为处于对应界面,然后执行该界面下定义的操作。这里用的是最经典的归一化相关匹配算法,通过计算模板在搜索图上的相似度得分来决定是否匹配成功,得分通常在0到1之间,设定一个合适的阈值就能把“像与不像”的界线切得比较干净。
为什么不用深度学习或者更复杂的YOLO目标检测?理由很简单:成本高、收益小。模板匹配对固定界面的识别又准又快,CPU跑起来毫无压力;深度学习需要标注数据、训练模型,对于两三个按钮的识别需求完全是大炮打蚊子。
2.2 输入模拟与游戏窗口控制
输入模拟选型上,我比较过pyautogui和pydirectinput这两个库。pyautogui用起来简单,但它模拟的是系统层面的鼠标事件,部分游戏会做输入过滤;pydirectinput直接调用DirectInput接口,兼容性更好,尤其适合游戏场景。我最终选了pyautogui做基础版本,因为自走棋操作不涉及高速连点,对输入精度的要求并不苛刻。
窗口控制方面,用pygetwindow按窗口标题定位游戏窗口,把脚本的所有操作都限制在窗口坐标系内,这样无论窗口拖到哪里,坐标计算都不会乱掉。
2.3 运行环境搭建
整个项目依赖不算多,核心就这几样:
- Python 3.9 及以上版本
- OpenCV-Python:负责图像识别
- Pillow:负责截屏处理
- PyAutoGUI:负责鼠标操作
- PyGetWindow:负责窗口定位
- NumPy:配合OpenCV做矩阵运算
安装直接用pip批量装就行。我习惯先建虚拟环境再装,避免把系统Python搞乱。如果你用的是Anaconda,一条conda create -n yjc python=3.9就能起环境,然后pip install opencv-python pillow pyautogui pygetwindow numpy。
这里有个小建议:模板匹配对分辨率敏感,不同分辨率下界面元素看起来可能一样,但像素数值不同,导致匹配度下降。所以我做了一版多分辨率适配,方法是按检测到的屏幕分辨率加载对应的模板目录,各目录里放的是同一套按钮在不同分辨率下的截图。
3. 脚本架构设计与核心模块拆解
3.1 四层架构:调度、状态、识别、操作
这套脚本的整体架构我分成了四层,每层只干一件事,互相之间通过接口通信。
最上层是调度层,负责读取任务列表,按照优先级和冷却时间决定当前该执行哪个任务。中间是状态层,用状态机维护整个执行流程,游戏的每个界面都对应一个状态。再往下是识别层,封装了截图、模板匹配、OCR这几个能力。最底层是操作层,负责具体的鼠标点击、拖拽和键盘输入。
这种分层的设计思路来自我写后端服务时的习惯。好处很直接:修改某一层的实现不影响其他层。比如识别层从模板匹配换成YOLO,上层完全无感;操作层从pyautogui换成pydirectinput,状态层也不用动。
3.2 状态机的设计思路
说到状态机,这是整个脚本的骨架,也是最值得细聊的部分。
自走棋的游戏流程可以概括为:主城 → 活动页 → 开始匹配 → 准备阶段 → 战斗阶段 → 结算 → 回到主城。每一个“屏”都是状态,状态之间通过条件转移连接。
我定义了几个核心状态:MAIN_CITY(主城)、ACTIVITY_PAGE(活动页)、MATCHING(匹配中)、LOADING(加载中)、BATTLE(战斗中)、SETTLEMENT(结算页)。
状态转移的触发条件有两类:一是界面特征切换,由识别层上报当前界面ID,状态层根据预定义的状态转移表判断下一步;二是超时触发,比如匹配超过90秒还没进去,就判定为匹配失败,切换到取消匹配状态。
这里有一个很多初写自动化脚本的人容易犯的错:用sleep硬等界面切换。这种写法最大的问题是把不确定性当确定性处理,网络卡一下加载就超时,后面所有流程全乱套。状态机的做法是把“等待”抽象成轮询,每0.5秒截一次图,检测到目标特征才继续下一步。虽然写起来稍麻烦,但稳定性提升是质的。
3.3 任务调度:优先级与冷却管理
日常任务虽然多,但天然存在优先级差异。比如高收益的限时活动,如果上线时还没结束,就得优先做;而体力领取这种固定产出,什么时候做都一样。
我的调度器维持了一个优先队列,每个任务注册时携带三个属性:优先级权重、冷却时间、执行函数。调度器每秒跑一次心跳,扫描所有任务,把当前可执行且权重最高的任务取出来执行。执行完根据任务类型决定是否进入冷却。
这个设计参考了操作系统的进程调度思路。虽然对于单机脚本来说有点重,但好处是后续加新任务非常方便,只要按接口注册一个任务对象,调度器自动管理,不需要改其他代码。
3.4 日志与数据记录模块
这个模块看起来不起眼,但实际排障的时候帮了我大忙。脚本每执行一步操作,都会记录一个结构化日志,包含时间戳、当前状态、识别结果、操作类型和耗时。截图也会按轮次保存到本地,方便事后复盘。
数据记录方面,每次完成一个任务都会把结果写进SQLite数据库。这样我可以统计每个任务的平均耗时、成功率、失败原因分布,从而持续优化脚本的执行策略。
4. 实操过程与关键脚本实现
4.1 模板采集与预处理流程
模板匹配的效果好坏,七成取决于模板图的质量,剩下三成才看算法参数。模板图的采集有一个标准流程,我一般是这么做的:
先把游戏窗口调整到合适大小,进入目标界面,把窗口置顶,然后截取整屏。用截图工具框选按钮区域,保存为独立的模板文件。注意模板图必须包含按钮周边的几个像素作为上下文特征,纯粹的按钮本体反而容易误匹配,因为很多按钮长得差不多。
采集完模板要进行预处理。第一是统一尺寸,所有模板要么做scale统一,要么在匹配时按比例缩放。第二是灰度化,颜色信息在模板匹配中用处不大,还容易被色差干扰,转成灰度能提升匹配鲁棒性。第三是边缘增强,我用的是Canny边缘检测后再做匹配,对光照变化更抗干扰。
模板文件我会按功能分类存放,目录结构大致是这样的:
templates/ ├── common/ │ ├── confirm_button.png │ ├── close_button.png │ └── back_button.png ├── main_city/ │ ├── activity_icon.png │ ├── mail_icon.png │ └── task_icon.png └── battle/ ├── start_button.png ├── ready_button.png └── settle_confirm.png这样后续加新任务的时候,只需要在对应目录里补充模板文件,再在任务配置表里注册一下,不用动核心代码。
4.2 核心代码:模板匹配与点击函数
我先把匹配和点击封装成两个基础函数,它们是所有上层操作的基石。匹配函数负责在截图中定位模板位置,点击函数负责在指定位置执行点击。
import cv2 import numpy as np import pyautogui import pygetwindow as gw def find_template(screenshot, template, threshold=0.8): """ 在截图中定位模板位置 返回匹配区域中心坐标,未匹配到则返回None """ # 转换为灰度图 gray_screenshot = cv2.cvtColor(np.array(screenshot), cv2.COLOR_RGB2GRAY) gray_template = cv2.cvtColor(template, cv2.COLOR_RGB2GRAY) # 执行模板匹配 result = cv2.matchTemplate(gray_screenshot, gray_template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(result) if max_val >= threshold: h, w = gray_template.shape center_x = max_loc[0] + w // 2 center_y = max_loc[1] + h // 2 return center_x, center_y return None def click_at(coords, delay=0.3): """ 在指定坐标执行点击 coords为(x, y)元组 """ x, y = coords pyautogui.moveTo(x, y, duration=0.15) pyautogui.click(x, y) time.sleep(delay)一个完整的操作序列就是“截图 → 匹配 → 点击”的循环。比如领取体力,思路就是先截图,在主城界面上匹配体力箱子的图标,找到就点它。
def collect_energy(): """ 领取体力 """ game_window = get_game_window() screenshot = capture_window(game_window) energy_icon = load_template('main_city/energy_icon.png') pos = find_template(screenshot, energy_icon) if pos is None: log_warning('未找到体力图标,可能不在主城界面') return False click_at(pos) time.sleep(1) # 在弹出的确认框中点击领取按钮 screenshot = capture_window(game_window) confirm_btn = load_template('common/confirm_button.png') confirm_pos = find_template(screenshot, confirm_btn) if confirm_pos: click_at(confirm_pos) return True这个函数初版就是纯手写流程线,一个任务一个函数。跑通之后我就发现了明显问题:代码重复率太高,状态判断和点击逻辑高度耦合,加一个新任务要复制粘贴一大段。后来才重构出状态机的模式。这个重构过程让我体会到,写自动化脚本前期别追求完美架构,先把流程跑通,根据痛点再逐步优化。一上来就设计大而全的框架,大概率会陷入过度设计的泥潭。
4.3 状态库实现:界面感知与跳转
状态库的核心是一个当前状态变量加一个更新函数。每次轮询时调用更新函数,内部重新截屏并识别界面特征,然后判断当前状态是否发生变化。
class GameState: def __init__(self): self.current = 'UNKNOWN' self.last_update = 0 def update(self): """刷新当前界面状态""" game_window = get_game_window() screenshot = capture_window(game_window) # 按优先级依次匹配各状态的特征模板 for state_name, feature_templates in STATE_TEMPLATES.items(): matched = False for tmpl in feature_templates: tmpl_img = load_template(tmpl) pos = find_template(screenshot, tmpl_img, threshold=0.75) if pos: matched = True break if matched: if self.current != state_name: log_info(f'状态切换: {self.current} -> {state_name}') self.current = state_name break self.last_update = time.time()STATE_TEMPLATES是每个状态联动的特征模板列表。一个状态用多个模板做并列匹配是有原因的,游戏加载中偶尔会有遮挡,一个模板没匹配上还能靠另一个兜底。我用2-3个模板投票,识别稳定性大幅提升。
4.4 战斗阶段的处理策略
自走棋的战斗阶段比较特殊,操作层面不需要怎么介入,主要考验的是脚本的稳定等待能力。战斗时长不固定,快则一两分钟,慢则五分钟,中间还有玩家掉线重连等意外。
我的做法是把战斗阶段拆成两个子状态:先检测“战斗正在进行”的特征图标,如果识别到就持续等待,同时监控异常弹窗,战斗结束的特征是“结算”按钮出现。
这里有一个值得讲的参数:轮询间隔。我一开始设的是0.3秒,CPU占用率太高,电脑都发烫。后来调整到0.8秒,识别效果没差别,资源消耗却降了一截。具体间隔还是要看你电脑配置,我最终定了0.5秒作为平衡点,既能快速响应,又不至于太吃资源。
4.5 主循环与异常重启机制
所有模块拼起来后,主循环就是一个标准的“心跳”:
def main_loop(): scheduler = TaskScheduler() state = GameState() running = True while running: try: state.update() current_task = scheduler.get_current_task(state.current) if current_task: current_task.execute() else: time.sleep(1) except Exception as e: log_error(f'执行异常: {e}') recovery_action(state) time.sleep(2)异常重启机制的关键在于recovery_action。如果识别层连续3次返回未知状态,说明发生了脚本预期外的情况,比如弹了一个没见过的新活动窗口。这时候如果继续执行很可能会乱点,我的策略是先按“ESC”键或点击通用关闭按钮,尝试回到主城界面。如果还是不行,就强制重置状态机,回到主城待命。
这个机制让我几个晚上挂着脚本的时候能睡个安稳觉。真正跑起来,遇到的最大工程挑战其实是环境差异。同一套代码,在这个模拟器上跑得好好的,换到另一个模拟器上就开始各种不识别。后来定位清楚了,是不同模拟器对窗口的渲染方式有细微差别,导致截图的色彩和尺寸有偏移。解决方案也很朴素,就是在多个环境里都采集一套模板,运行时根据当前模拟器类型动态切换模板目录。
5. 常见问题与排查技巧实录
5.1 模板匹配误识别
我碰到的第一个高频问题是误识别。一个典型的场景是:主城界面的“任务”按钮和活动界面的“任务”按钮长得几乎一模一样,模板匹配时经常搞混,导致状态机跳到了错误分支。
一开始我通过降低阈值试图解决,结果适得其反,阈值一低,不相关的东西也被识别出来了。后来改用多模板投票策略,每个界面状态绑定2到3个独立特征模板,必须匹配到至少两个才确认状态。误识别率显著下降。
排查这个问题的思路值得借鉴:先检查模板图本身是否存在歧义区域,再看阈值设置是否合理。我见过不少人的模板匹配直接拿全图标截图,图标里的数字、角标都会干扰匹配,裁剪模板时要把这些干扰元素剔干净。
5.2 死等与超时配置
脚本跑着跑着就不动了,这类问题排查起来最费时间。原因通常是只写了匹配代码而没写超时容错。比如等待加载完成,适配代码会无限循环地截图、匹配、再截图,一旦游戏卡死或者网络异常导致界面一直不变化,脚本就永久挂起。
针对这个坑,我给每个等待状态加了一个“超时跳转”配置,超过设定秒数仍未检测到目标特征就触发异常处理。具体时长参考了日常体验:匹配等待最长90秒,加载等待最长30秒,战斗检测最长5分钟。超时后先触发一次重试,重试仍失败就把状态重置回主城。
5.3 多开窗口坐标偏移
脚本跑单窗口时一切正常,一旦我开双号就不行了。排查发现是窗口位置偏移导致的。虽然脚本是按窗口坐标计算,但截屏时的坐标系和操作时的坐标系不一致,窗口稍微偏移,点击就点歪。
解决办法是先获取窗口的真实边界,再建立坐标系映射。所有识别和点击全部转换到窗口内相对坐标,不直接用屏幕全局坐标。
def get_game_window(): """获取游戏窗口并建立坐标映射""" windows = gw.getWindowsWithTitle('一将成名') if not windows: raise Exception('未找到游戏窗口') win = windows[0] if win.isMinimized: win.restore() win.activate() time.sleep(0.5) return { 'handle': win, 'left': win.left, 'top': win.top, 'width': win.width, 'height': win.height, } def to_window_coords(win, point): """将全局坐标转换为窗口坐标""" x, y = point return x - win['left'], y - win['top'] def to_global_coords(win, point): """将窗口坐标转换为全局坐标""" x, y = point return x + win['left'], y + win['top']这样改造后,窗口随意移动都不影响识别的准确性。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 脚本启动后无反应 | 未找到游戏窗口 | 检查窗口标题是否匹配,确认游戏窗口未最小化 |
| 频繁误识别 | 模板图裁剪不当 | 重新裁剪模板,移除数字、角标等噪声元素 |
| 界面等待永久卡住 | 缺少超时机制 | 为等待逻辑增加超时配置,超时自动重置 |
| 点击位置偏移 | 坐标系未统一 | 统一使用窗口相对坐标,避免直接使用全局坐标 |
| CPU占用过高 | 轮询间隔过短 | 调大轮询间隔,从0.3秒调到0.5秒以上 |
| 偶发识别失败 | 模板过小或模糊 | 适当增大模板匹配的搜索范围,重新截取高分辨率图 |
6. 脚本的合规边界与扩展空间
关于这类脚本,我觉得有必要说几句。游戏自动化的合规边界一直是个灰色问题,不同游戏的态度也不同。就自走棋这种PVE为主的玩法,日常自动化通常不会破坏其他玩家的体验,但使用第三方工具仍然可能违反游戏用户协议。我的建议是:自己研究、自己用,别规模化传播,更不要拿去做代练或者盈利。脚本这东西,当成学习自动化的项目来研究,价值远比直接“用”它更大。
技术上的扩展空间其实不小。当前版本用的是模板匹配,对于动态内容(比如活动弹出框里变化的文字)识别率不高。想进一步提升,可以引入OCR来做文字识别,或者用目标检测模型做更通用的元素识别。这又是一个大型工程了,目前的方案足够轻量,没必要过度投入。
另外,这套状态机加调度器的架构完全可以复用到其他自走棋游戏的自动化中。界面变了,模板重截一套就行;逻辑变了,状态转移表改一下就行。核心骨架的复用价值很高。
我在跑这套脚本的过程中,最大的收获不是“日常任务全自动了”,而是把“怎么把不确定的界面流转变成稳定的程序逻辑”这个问题真正想明白了。自动化不只是写代码,更是对业务逻辑的深度拆解。如果你也想做类似的东西,我建议从小任务入手,先做一个自动领取每日奖励的脚本,跑通整个链路再逐步加任务。一口吃不成胖子,但慢慢来就能把整个流程养成一个成熟的自动化系统。