☰
Python+OpenCV+ADB实战:FGO自动战斗脚本与状态机设计
2026/10/5 1:04:34 网站建设 项目流程

玩FGO玩到后期,最累的其实不是打不过,而是重复刷同一个本。活动期间每天要清体力、刷素材、打周回,操作千篇一律:进本、选好友支援、选卡、放宝具、结算、继续下一把。打到后面完全是肌肉记忆,眼睛盯着屏幕,手上点点点,脑子里早就放空了。

这个项目的出发点就是把这套机械操作交给代码。用Python写一个FGO自动战斗脚本,核心思路不复杂:截屏、看图、判断当前界面、模拟点击。本质上就是把"看屏幕→做决策→点屏幕"这个过程自动化。它不修改游戏内存数据,不注入什么外挂模块,就是用OpenCV做图像匹配,用ADB把触摸事件发到设备上,跟人手动操作的路径是一致的。

这篇文章把整个项目的实现过程完整记录下来,包括方案选型、环境搭建、核心代码、状态机设计、常见报错和稳定性处理经验。顺便说清楚边界:脚本只做界面识别和模拟点击,属于个人便利工具,自己挂着清体力没问题,但别拿去碰排行榜、竞速这类影响他人体验的场景,风险心里有数就行。适合两类人看:一是FGO玩家,想省点重复劳动;二是学Python的朋友,这个项目作为OpenCV和自动化方向的练手非常合适,麻雀虽小,该有的点都有。

1. 整体思路与方案选型

1.1 自动战斗脚本到底要解决什么问题

FGO的战斗循环说穿了就三步:进图、打完、拿奖励。手动刷一把周回本,出的指令无非是点击开始、选择支援、等待读条、放技能、选三张卡、等待战斗动画、点确认结算。整个过程没有任何智力含量,纯粹是重复劳动,但它占据了活动期间每天大量的时间。

自动战斗脚本要做的,就是把这套重复劳动拆成一串"识别界面→执行动作"的小任务。难点在于FGO的界面状态非常多:有主线关卡选择、好友支援选择、战斗指令、技能确认、结算界面、材料掉落、AP不足的提示……每个状态对应的按钮位置和操作都不同。脚本得像人一样,每时每刻先判断"现在屏幕上是什么界面",再决定"下一步点什么"。

这里面有个边界要提前说清楚:脚本只做界面识别和模拟点击,不做任何数据篡改、加速、倍攻之类的行为。后者属于完全不同的技术路线,风险和性质也完全不同。用图像识别加自动操作的方式,从行为逻辑上和手动刷本是一致的,这也是这个项目能作为技术练习对象的原因。

1.2 技术选型:为什么是Python + OpenCV + ADB

这个方案不是唯一选项,我一开始也对比过其他路线。

Airtest和Appium这类UI自动化框架也能做,但问题是它们主要为App测试设计,让你写各种查找控件的逻辑。然而FGO这类游戏的界面绝大多数是自绘渲染,根本拿不到控件的层级树,绕到最后还是得靠截图识别,框架反而成了负担。uiautomator2在部分安卓设备上能注入触摸事件,但很多国产ROM权限管控严格,容易失灵,而且对模拟器的支持也不稳定。

Python + OpenCV + ADB就简单直接得多。ADB是安卓官方调试工具,只要开了USB调试,几乎所有设备都能发触摸事件;OpenCV负责图像匹配,游戏界面不管是不是自绘渲染,只要是显示出来的画面都能截屏识别;Python负责把它们串起来,写起来快,调试也方便。这套技术栈在各类自动化脚本里算是最轻量、最通用的组合,学会了不只在FGO上能用,其他游戏的简单自动化,甚至一些桌面工具的自动化也能迁移。

具体的工具清单就是这些:

  • Python 3.9以上
  • opencv-python:图像匹配的核心库
  • numpy:处理图像数组运算
  • Pillow:偶尔做图像格式转换和预处理
  • platform-tools里的adb.exe:设备交互工具

1.3 状态机:把复杂流程拆成独立的小状态

战斗流程看着长,但拆开之后,本质是一串互相独立的状态。状态机的概念听起来高大上,其实说穿了就是给每个界面场景定义一个状态,脚本主循环里先看当前处于什么状态,再根据识别结果决定要不要切换到下一个状态。

我最初写的版本是一长串if-else,从进本点到结算一路写下来,结果运行了十分钟就乱套。原因很简单:战斗流程里分支太多了。可能没AP了、可能遇到好友支援刷新、可能队伍强度不够打成了一场长线战斗、可能网络波动多了一个加载界面。任何一个意外情况都会让线性的if-else彻底跑飞。

后来改成状态机,每个状态只关心"我在这个界面应该做什么,做什么之后能走到下一个状态"。比如支援选择状态只做一件事:在画面里找第一个支援角色并点击,找到就切到战斗指令状态,找不到就继续等。各状态之间互不干扰,出问题的时候也能很快定位是哪个环节卡住了。这个改造是整个项目稳定性提升的关键一步。

2. 环境准备与基础配置

2.1 Python安装与环境变量

这部分看起来基础,但真的有很多人卡在这里。我见过同事在Windows下装完Python,命令行输python还是提示找不到,十有八九是安装时没勾选"Add Python to PATH"。

安装时注意:安装包里那个"Add Python to PATH"复选框一定要勾上。没勾的话,装完之后手动把Python安装目录和Scripts目录加到环境变量里也行。装完在命令行执行python --version和pip --version,能正常输出版本号就说明基础环境OK。这一步也是这个项目里最没有技术含量、但最不能跳过的部分。

国内环境下装第三方库,pip默认从官方源下载可能非常慢。我习惯在用户配置里把源切换成国内镜像,一劳永逸。在用户目录下建一个pip.ini(Windows)或者pip.conf(Linux/macOS),写入index-url指向国内镜像地址。临时用的话,也可以每次用pip install -i https://pypi.tuna.tsinghua.edu.cn/simple <包名>。我当时用的清华的PyPI镜像,速度和稳定性都不错。

2.2 安装依赖库

基础依赖就三个库,命令行执行:

pip install opencv-python numpy pillow

opencv-python是图像识别的核心,后面所有模板匹配都靠它;numpy是OpenCV依赖的数组运算库,装OpenCV的时候一般会自动带上,但显式装一遍能避免后续乱七八糟的版本问题;pillow做图像格式转换时用得上,比如把截图转成可以交给OpenCV处理的numpy数组。想确认装好没有,进Python跑一下import cv2,不报错就说明环境没问题。

如果后面想调试图像匹配效果,我建议再加一个matplotlib。它可以把匹配结果直接框出来画在图上,比只看匹配数值直观太多。这个不是必须的,但是调试阶段非常提升效率,尤其是调整阈值和裁剪模板的时候。

2.3 ADB连接与设备调试

ADB是安卓官方的调试工具,脚本和手机之间的所有交互都是通过它完成的。安装方式最简单的是直接下载官方platform-tools,里面自带adb.exe,解压后把路径加到PATH里。

手机端需要开启开发者选项和USB调试。不同品牌的开启方式略有差异,但基本都是:设置、关于手机、连点版本号七次、进入开发者模式、打开USB调试。插上数据线之后,手机会弹一个"是否允许USB调试"的授权框,要手动点一下允许。

命令行先跑adb devices,能看到设备编号,后面带device状态就说明连接成功。如果是用模拟器,则先在模拟器设置里开启ADB调试,然后adb connect 127.0.0.1:端口号连接。

连接成功后,第一件事是确认分辨率和DPI。FGO的UI是固定按照分辨率缩放的,不同分辨率下按钮位置完全不同。为了保证后续模板匹配的准确率,最好固定用一个分辨率,我实测720x1280、1080x1920、1440x2560都可行,但选定之后就不要中途改。用adb shell wm size和adb shell wm density可以查看当前设备的屏幕尺寸和DPI。

3. 核心实现:截图、识别与模拟点击

3.1 截屏方案:用adb exec-out而不是screencap

自动战斗的第一步是让脚本"看到"屏幕。Android上通过ADB截屏有两种常见姿势:adb shell screencap -p /sdcard/screen.png后再adb pull拉回本地,或者直接adb exec-out screencap -p > screen.png把截图字节流输出到本地。我强烈推荐第二种,少了一次pull操作,速度快很多,对脚本的循环频率也有帮助。

有个细节值得提醒:早期版本的adb在Windows下用screencap重定向输出,会因为平台差异在PNG文件头部多出几个字节,导致图片无法正常解码。用exec-out的方式没有这个问题,这也是我坚持用它原因。Python里用subprocess调用ADB,把stdout读成bytes,再交给OpenCV解码:

import subprocess import cv2 import numpy as np def capture_screen(): raw = subprocess.run( ["adb", "exec-out", "screencap", "-p"], capture_output=True ).stdout img = np.frombuffer(raw, dtype=np.uint8) return cv2.imdecode(img, cv2.IMREAD_COLOR)

这段代码是整个脚本的地基。返回的img是一个BGR格式的numpy数组,宽高对应屏幕分辨率,后面所有模板匹配都在这张图上进行。

3.2 模板匹配的原理与关键代码

OpenCV模板匹配的原理可以理解为:拿着小图片在大图片上滑动,每到一个位置计算一次相似度,相似度最高的位置就是小图片出现的坐标。放到这个项目里,大图片是当前屏幕截图,小图片是从游戏里截出来保存好的按钮、界面元素。

我用的是cv2.TM_CCOEFF_NORMED这个匹配方法,它会输出一个从-1到1的值,越接近1说明匹配度越高。一般来说,干净界面上按钮的匹配值都在0.9以上,所以初始阈值可以设在0.85左右。核心函数写成这样:

def find_template(screen, template_path, threshold=0.85): template = cv2.imread(template_path) h, w = template.shape[:2] res = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) if max_val >= threshold: return max_loc[0] + w // 2, max_loc[1] + h // 2, max_val return None, None, 0

返回值是模板中心点的坐标。中心点坐标就是点击要用的坐标,因为人点按钮也是点在中心。匹配不上就返回空,调用方根据空值决定是继续等待还是做别的处理。

写代码的过程中我发现一个很实际的问题:同一台设备白天和晚上截屏,因为游戏内背景亮度、界面加载动效不同,同一个按钮的显示效果会有细微差异。所以模板最好是直接从自己设备上截,并且截完做一次裁剪,只保留按钮主体,不要带太多背景。背景部分越少,匹配的干扰就越少。

3.3 模拟点击、滑动与按键的ADB封装

ADB模拟点击的命令是adb shell input tap x y。为了让脚本代码简洁,我把它封装成一个函数,并且允许传入点击延迟,避免动作太连贯触发游戏的反自动化检测逻辑:

import time import subprocess def adb_tap(x, y): subprocess.run(["adb", "shell", "input", "tap", str(x), str(y)]) time.sleep(0.3)

滑动命令用adb shell input swipe,格式是起点坐标、终点坐标、持续时间。游戏里基本只有两个地方用到滑动:一个好友支援列表的滚动选择,一个是要往下拉才能点到继续按钮的结算界面。按键命令用adb shell input keyevent,最常用的是返回键,keyevent编码是4。

把"截图、找模板、点击"组合起来,就得到一个高级函数,这也是整个脚本里最常用的一个动作:

def click_if_found(screen, template_path, threshold=0.85): x, y, score = find_template(screen, template_path, threshold) if x is not None: adb_tap(x, y) return True return False

这个函数的意思是:屏幕上出现目标就点,不出现就返回False。后面所有状态流转都建立在类似的函数上,逻辑简洁,也好排查。

4. 战斗流程的状态机实现

4.1 战斗流程拆解与状态定义

在1.3节我说了用状态机控制整个流程,这里给出具体落地。整套流程被我拆成这几个状态:

  • START:战斗开始前的界面,找"开始战斗"或"出击"按钮
  • SUPPORT_SELECT:进入好友支援选择界面,选第一个支援
  • LOADING:等待剧情和资源加载,这个状态不点击,只检测加载是否结束
  • BATTLE_INSTRUCTION:战斗指令阶段,处理选卡、技能、攻击
  • BATTLE_ENEMY:敌人行动阶段,等待敌方动画结束
  • BATTLE_RESULT:结算界面,点继续或收集奖励
  • CHECK_AP:检查AP是否足够,不足则停止并记录日志

每个状态对应一个处理函数,统一返回下一个状态名,主循环就是一张状态转移表:

state = "START" while True: screen = capture_screen() if state == "START": if click_if_found(screen, "imgs/start_button.png"): state = "SUPPORT_SELECT" elif state == "SUPPORT_SELECT": if click_if_found(screen, "imgs/support_first.png"): state = "LOADING" elif state == "LOADING": if click_if_found(screen, "imgs/battle_ui.png"): state = "BATTLE_INSTRUCTION" elif state == "BATTLE_INSTRUCTION": state = process_battle(screen) # ... 其他状态处理 time.sleep(0.5)

这里最关键的设计是两个原则:每个状态循环里都重新截屏,保证决策基于最新画面;每次只做一步动作,动作之后马上重新截屏。状态与状态之间靠界面元素是否存在来迁移,而不是靠计时器强行切换。这样即使某一步动作没生效,也能在下一轮循环里再次尝试。

4.2 选卡策略与随机化

战斗指令阶段是脚本的核心。FGO战斗界面底部有5张指令卡,每张卡需要选中,然后点攻击按钮。识别指令卡的类型当然可以,但非常繁琐,更实用的方法是按位置划分:5张卡均匀分布在屏幕底部固定区域,我把这块区域分成5个等宽的分区,每个分区中心点就是一张卡的位置。

选哪3张是个策略问题。最省事的做法是从5张卡里随机抽3个不重复的位置点击。虽然不看卡面颜色,输出不是最优,但周回本本身难度低,随机打基本都能过,而且代码量小很多:

import random def select_cards(screen): h, w = screen.shape[:2] card_width = w // 5 cards_y = int(h * 0.82) selected = random.sample(range(5), 3) for idx in selected: x = int((idx + 0.5) * card_width) adb_tap(x, cards_y) time.sleep(0.2) click_if_found(screen, "imgs/attack_button.png")

后续如果想优化,可以根据颜色判断卡的类型:红卡整体偏红、蓝卡偏蓝、绿卡偏绿,用OpenCV的HSV通道做颜色筛选,然后优先选某一种颜色。但我实测下来,普通周回本纯随机选卡的成功率已经有95%以上,颜色优化的边际收益不高,除非你要打高难本。

4.3 技能和宝具的简化处理

技能和宝具的判断是脚本复杂度的主要来源。FGO的技能按钮在战斗界面右侧,宝具和攻击按钮在右下角。每个从者有三个技能,状态可能是已释放或未释放,用同一套模板匹配来处理就可以。

我第一版实现完全没做技能判断,只点攻击按钮和选卡,已经能让一个中配队伍稳定刷大部分周回本。后来为了刷得更快,加了一个优先放宝具的逻辑:战斗指令阶段先检测右侧宝具按钮,如果存在,说明宝具值满了,点一下,再回到选卡逻辑。

放技能的操作顺序容易踩坑:必须先点技能按钮弹出确认,再点确认按钮,最后回到选卡。每个确认按钮都得有对应的模板,否则脚本会把确认释放界面当成普通画面,导致技能没放出去,白白浪费一个回合。我的建议是:第一版脚本不要做技能和宝具,先把选卡打通的成功率跑到90%以上,再考虑加这些复杂操作。

4.4 超时兜底与异常恢复

写到第四版的时候,脚本最大的问题已经不是识别不到,而是某个状态卡住了。卡住的原因五花八门:网络波动带来的额外加载、好友支援列表多弹了一个公告、FGO出了个新的临时弹窗。为此我加了两层保护。

第一层是状态超时。每个状态在进入时记录时间,如果在当前状态停留超过预设上限,比如LOADING最多等30秒、BATTLE_INSTRUCTION最多等60秒,就强制做一次兜底操作:优先尝试点屏幕中部的确定位置关掉弹窗,再不行就按返回键,超时控制逻辑里重置状态到START。这个策略虽然粗暴,但覆盖率很高,因为FGO绝大多数异常界面只有这两条出路。

第二层是全局看门狗。脚本记录最近一次状态迁移时间,如果超过3分钟没有任何状态变更,直接重新执行一遍完整的回主页、进本流程。这两层机制配合起来,能让脚本在无人值守的情况下撑过一整晚的连续刷本而不卡死。日志里也会记录每次兜底触发的位置和原因,方便第二天复盘。

5. 常见问题与排查实录

5.1 ADB设备识别不到或者显示未授权

这是新手遇到最多的一个问题,我的排查顺序一般是这样:

现象原因解决办法
adb devices没有输出adb不在PATH,或驱动没装好确认adb路径,确认USB已连接且手机处于解锁状态
设备状态显示unauthorized手机上的USB调试授权弹窗没点允许重新插拔数据线,在手机上点允许USB调试
模拟器连不上端口不对或者模拟器ADB功能没开在模拟器设置里开启ADB调试,查看对应端口后用adb connect连接
连接后频繁掉线USB线质量差或者接口松动换一根短的好线,或者改用模拟器加本地连接

还有个经常踩的坑:adb devices能看到设备,但跑screencap一直超时。这个多半是设备端截图时CPU占用过高,游戏本身很吃性能。解决方法是给模拟器分配更多核心和内存,真机的话要保证游戏运行不卡顿。

5.2 模板匹配不上或者误匹配

匹配不上的原因七成出在"模板和当前画面不一致":分辨率变了、游戏UI版本更新了、按钮位置被其他元素挡住了。我的习惯是给脚本加日志输出,某个模板频繁失效时,直接把匹配分数打印出来,低于0.7基本就是模板需要重新制作了。

误匹配是另一个常见问题。比如"开始战斗"按钮和"继续战斗"按钮长得很像,模板匹配会混淆。解决办法有两条:一是提高阈值,比如从0.85提到0.92;二是给关键按钮准备多张模板,一张截按钮本身,一张截按钮加上一截独特背景的模板。匹配的时候只要任何一个模板匹配成功就算找到,误判率会明显下降。

5.3 脚本越跑越乱,状态错乱

这种情况多半是识别到但执行时机不对造成的。举例:结算界面有一点延迟,脚本以为已经回到主页点了开始,结果点到的是刚才的战斗结束弹窗。解决思路是加防抖:连续多次截屏都识别到同一元素,才认为状态稳定,再执行动作。我在代码里把识别一次改成连续识别三次,间隔0.5秒,三次都匹配才动作,稳定性提升非常明显。

日志也特别重要。我给脚本加了一个简单的log记录器,每一轮循环记录当前状态、识别到的模板、匹配分数和点击坐标。后期排查的时候,打开日志一眼就能看到脚本是在哪个状态、因为哪个模板没有匹配上而卡住。这个习惯建议所有做自动化脚本的朋友都养成。

5.4 设备层面的稳定性细节

几个没写进代码但实测非常影响稳定性的点:

  • 手机或模拟器屏幕亮度要固定,最好关掉自动亮度。亮度变化会直接影响模板匹配的分数。
  • 关掉通知栏的弹窗推送。游戏结算时如果上方弹出微信或系统通知,会抬高模板匹配的干扰值,容易误判。
  • 模拟器环境下,尽量锁定一个分辨率和DPI,不要跟着窗口大小自适应变化。
  • 脚本开始前手动把游戏放到主页,让START状态从开始战斗按钮找起,能跳过队伍编成等复杂前置。
  • 保证设备和电脑都不休眠。长时间运行的话,USB供电和屏幕常亮都要处理好。

6. 实测效果与扩展方向

6.1 实测效果

我自己在MuMu模拟器上,720x1280分辨率,用一支中配主线队伍做测试,稳定运行了4个小时,刷了大概120把周回本,人工核对了结果:成功119把,唯一失败的一次是遇到网络重连弹窗,看门狗没能正确恢复。

纯随机选卡的效率比手动操作慢10%到15%,因为手动玩会刻意选红卡和攒宝具,脚本是随机点。但如果是上班挂着、通宵刷素材的场景,脚本不用人管这个优势,远超那点效率差距。AP用完之后的处理,我暂时做成检测到体力不足弹窗就停止运行并记录日志,没有接自动吃苹果的逻辑,主要担心误操作把攒的珍贵道具吃了。

6.2 后续还能怎么扩展

这个脚本的框架其实很通用,有兴趣可以继续往里加东西:

  • 把模板匹配换成YOLO这类目标检测模型,直接检测卡面颜色和按钮文字,识别准确率更高,对分辨率变化也更有鲁棒性。
  • 加一个OCR模块,读取当前AP数值和素材掉落数量,精确控制停止刷本的时机。
  • 用APScheduler做定时任务,比如每隔几小时自动启动一轮脚本,实现体力满了自己刷的全自动循环。
  • 多个模拟器实例同时跑,每个实例独立运行一份脚本,效率直接翻倍。

最后提一句个人体会:脚本这类东西自己用来省点事是好事,但别去碰游戏里竞速、排行榜这些会明显影响其他玩家体验的玩法,图个方便就好。刷出来的材料是虚拟的,但通过这个项目学到的图像识别加自动控制这套方法,是真的能迁移到别的自动化场景里的硬功夫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询