简介:这份资源是面向Android开发学习者与FGO玩家的自动战斗应用解析资料,围绕Fate/Grand Order Auto Battle展开,讲解如何借助Kotlin语言、OpenCV图像识别、无障碍服务与MediaProjection屏幕捕获等技术,实现游戏画面元素识别、自动点击与战斗流程自动化,适合想了解Android自动化控制与计算机视觉落地的中高级开发者参考。压缩包为zip格式,大小约2.62MB,上游未提供具体文件清单,故不展开文件类型明细。目前已有2613人学习下载,具备一定参考热度。读者可从中获取自动战斗应用的完整技术拆解思路,包括各技术框架的职责划分、图像识别与界面监听的配合方式,以及自动化操作在真实游戏场景中的实现要点,同时也能了解此类工具在游戏条款与账号安全方面的注意事项,便于对照自身项目进行技术选型与方案设计。
1. 从“每天点几百次技能”说起:FGA 到底解决了什么
玩 FGO 的人大概都经历过这种场景:活动刷本,一管体力下去,手指在屏幕上点技能、点宝具、点攻击,重复几十遍,眼睛盯着屏幕却什么也没看进去。有人干脆把手机架在桌上,隔几秒戳一下,结果一个走神就卡在选卡界面。FGA 这个项目就是冲着这个痛点来的——它是一个跑在 Android 上的自动战斗应用,用 Kotlin 写主体逻辑,靠 OpenCV 做图像识别,通过无障碍服务(AccessibilityService)模拟点击,再用 MediaProjection 抓屏幕画面。换句话说,它不碰游戏内存、不改包、不注入,纯粹“看屏幕 + 点屏幕”,这也是它能长期用下去的关键。适合谁?想解放双手刷素材的玩家,以及想学 Android 图像识别 + 自动化点击这套组合拳的开发者。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序,把这份源码拆开讲。
2. 技术栈与运行链路:一次战斗从截图到点击的完整路径
2.1 四个核心组件各管一段
FGA 的运行链路可以拆成四段,每段对应一个技术点,缺一不可。第一段是画面采集,用 Android 的 MediaProjection API 拿到屏幕帧,转成 Bitmap 或直接喂给 OpenCV 的 Mat。第二段是图像识别,OpenCV 做模板匹配(matchTemplate)和颜色/特征判断,回答“现在屏幕上是不是出现了某个按钮”。第三段是决策,Kotlin 里维护一个战斗状态机,根据识别结果决定下一步该点技能还是点攻击。第四段是执行,AccessibilityService 派发点击事件,坐标来自识别结果或预设区域。
这套设计的妙处在于解耦:识别层不知道点击层存在,决策层只消费识别结果。你换一个游戏,理论上只要换模板图和状态机,点击和采集两层可以原样复用。常见做法是把模板图放在 assets 目录,启动时加载成 Mat,运行时按区域裁剪再匹配,减少全屏扫描的开销。
// 画面采集:MediaProjection 拿到 ImageReader 的帧 val imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2) virtualDisplay = mediaProjection.createVirtualDisplay( "fga-screen", width, height, resources.displayMetrics.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, imageReader.surface, null, null ) imageReader.setOnImageAvailableListener({ reader -> val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener // 转 Bitmap 后交给识别线程,注意及时 close 防止缓冲区耗尽 val bitmap = imageToBitmap(image) image.close() recognitionQueue.offer(bitmap) }, handler)这段代码里几个参数值得说清楚:width/height建议取屏幕实际分辨率,别用缩放后的值,否则模板匹配的坐标换算会多一层误差;maxImages = 2是缓冲区上限,设太小会丢帧,设太大内存涨得快,2 到 3 是常见折中;acquireLatestImage之后必须close,这是血泪经验,忘了关几次就报“max images has already been acquired”。识别放到独立线程或协程里做,别在回调里直接跑 OpenCV,否则采集回调被阻塞,帧率直接崩。
2.2 模板匹配的参数怎么调
识别层的核心是Imgproc.matchTemplate。FGA 这类项目通常对每个 UI 元素准备一张小图(技能图标、攻击按钮、卡牌边框),在屏幕的候选区域内做匹配,取最大响应值,超过阈值就认为“出现了”。
fun matchTemplate(screen: Mat, template: Mat, threshold: Double = 0.85): Point? { val result = Mat() // TM_CCOEFF_NORMED 对亮度变化不敏感,适合游戏 UI Imgproc.matchTemplate(screen, template, result, Imgproc.TM_CCOEFF_NORMED) val mmr = Core.minMaxLoc(result) result.release() return if (mmr.maxVal >= threshold) mmr.maxLoc else null }TM_CCOEFF_NORMED返回的是归一化相关系数,范围大致 -1 到 1,越接近 1 越像。阈值 0.85 是个起点,实际要按元素调:图标类元素可以到 0.9,带文字或半透明遮罩的按钮可能得降到 0.75。调太低会误判,比如把“技能冷却中”的灰图标认成可用状态,结果点了个寂寞;调太高又漏判,战斗直接卡住。我一般会先把匹配结果的最大值打到日志里,跑几场看分布,再定阈值,比拍脑袋靠谱。另外记得在匹配前把屏幕图转灰度(Imgproc.cvtColor到COLOR_BGR2GRAY),速度能快一截,对彩色图标影响也不大。
2.3 状态机与点击派发
识别出“现在是什么界面”之后,决策层用一个状态机推进。典型状态包括:主界面、选关、战斗中、结算。战斗中又细分“等待技能可用”“选卡”“选目标”。每个状态对应一组可执行动作,动作执行后等一小段时间再重新截图判断,形成闭环。
sealed class BattleState { object Idle : BattleState() object InBattle : BattleState() object SelectingCard : BattleState() object Finished : BattleState() } // 点击派发:AccessibilityService 按坐标点 fun tap(x: Float, y: Float) { val path = Path().apply { moveTo(x, y) } val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, 50)) .build() service.dispatchGesture(gesture, null, null) }dispatchGesture的duration设 50ms 左右比较自然,太短可能被系统或游戏忽略,太长会拖慢节奏。坐标要基于当前屏幕分辨率算,别写死像素值,换台设备就全偏了。状态机里每个动作后加 300 到 800ms 的随机延迟,既模拟人手,也避免连续点击被判定异常。这里有个容易翻车的点:截图和点击之间有时间差,如果画面在变(比如宝具动画),识别到的坐标可能已经过期,所以动作前最好再确认一次状态。
3. 把项目跑起来:环境、权限与首次启动
3.1 编译环境与依赖
这是个标准 Android 工程,用 Gradle 构建,语言是 Kotlin。你需要 Android Studio(建议较新版本)和 JDK 17 左右的运行环境。OpenCV 的接入方式常见有两种:一是用官方 OpenCV Android SDK 的 aar,二是通过 Maven 依赖。源码里如果带了opencv模块或jniLibs下的.so,说明是本地集成,编译时注意 ABI 过滤,只保留arm64-v8a和armeabi-v7a能显著减小包体。
# 克隆后先看依赖和 SDK 版本 ./gradlew :app:dependencies --configuration releaseRuntimeClasspath # 编译 debug 包 ./gradlew :app:assembleDebug如果编译报 OpenCV 符号找不到,八成是.so没打进对应 ABI,检查build.gradle里的ndk { abiFilters }和jniLibs.srcDirs。另外minSdk别低于 24,MediaProjection 和手势派发在低版本上行为不一致,踩过坑的都懂。
3.2 三个权限一个都不能少
FGA 要正常工作,必须拿到三类权限,缺一个链路就断。第一是无障碍服务,在系统设置里手动开启,它负责点击;第二是屏幕录制授权,每次启动应用会弹 MediaProjection 的确认框,这是系统强制的,没法绕过;第三是悬浮窗权限(如果项目带悬浮控制面板),用于在游戏上层显示启停按钮。
<!-- AndroidManifest 里声明无障碍服务 --> <service android:name=".service.FgaAccessibilityService" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" android:exported="true"> <intent-filter> <action android:name="android.accessibilityservice.AccessibilityService" /> </intent-filter> <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibility_config" /> </service>accessibility_config.xml里要声明canPerformGestures="true",否则dispatchGesture直接无效。很多人第一次跑发现“识别正常但点不动”,就是漏了这个属性。屏幕录制授权每次进程重启都要重新确认,这是系统设计,别想着持久化,常见做法是在应用内做个引导页,把三步权限串起来讲清楚。
3.3 首次运行的验证顺序
别一上来就开自动战斗,按这个顺序验证更稳:先确认截图正常(应用内预览能看到实时画面),再确认单个模板能匹配上(日志打出坐标),然后手动触发一次点击看游戏有没有反应,最后才开状态机跑完整流程。每一步都能独立排查,比一股脑跑起来再猜哪里坏了高效得多。分辨率适配也要在这一步确认:模板图是在哪台设备上截的,换设备后匹配率会掉,必要时按屏幕宽度等比缩放模板再匹配。
4. 核心模块拆解:识别、决策、执行三层怎么落地
4.1 识别层:区域裁剪与多模板管理
全屏匹配又慢又容易误判,合格的做法是先按 UI 布局划区域。比如技能按钮固定在屏幕下半部,就只裁下半部去匹配;选卡界面只扫卡牌区域。区域坐标同样按屏幕比例存,运行时换算成像素。
data class TemplateSpec( val name: String, val assetPath: String, val regionRatio: RectF, // 相对屏幕的比例区域 val threshold: Double ) fun loadTemplates(context: Context, specs: List<TemplateSpec>): Map<String, Mat> = specs.associate { spec -> val bytes = context.assets.open(spec.assetPath).readBytes() val mat = Imgcodecs.imdecode(MatOfByte(*bytes), Imgcodecs.IMREAD_GRAYSCALE) spec.name to mat }regionRatio用比例而不是绝对像素,是为了跨设备复用;IMREAD_GRAYSCALE直接读灰度图,省一次转换。模板管理上,我一般把“元素名 → 模板 + 区域 + 阈值”做成配置,新增一个可识别元素只改配置不改代码,二次开发时这点很省事。识别结果统一返回“元素名 + 置信度 + 坐标”,上层不关心 OpenCV 细节。
4.2 决策层:状态迁移的边界条件
状态机的难点不在正常流程,在边界。比如技能点完进入冷却,识别不到可用图标,状态该停在“等待”而不是误判成“可以攻击”;宝具动画期间屏幕全白或全黑,任何模板都匹配不上,这时候要能识别“动画中”并等待,而不是疯狂重试。常见做法是给每个状态设超时,超时后回退到上一个稳定状态重新判断。
fun step(current: BattleState, screen: Mat): Pair<BattleState, Action?> { return when (current) { BattleState.InBattle -> { val atk = recognizer.find("attack_button", screen) if (atk != null) BattleState.SelectingCard to Action.Tap(atk) else BattleState.InBattle to Action.Wait(500) } BattleState.SelectingCard -> { val card = recognizer.find("card_face", screen) if (card != null) BattleState.InBattle to Action.Tap(card) else BattleState.SelectingCard to Action.Wait(300) } else -> current to null } }Action.Wait的毫秒数别设太小,否则截图频率过高,CPU 和电量都吃不消;也别太大,节奏拖沓。500ms 左右是常见值。状态迁移要保证“任何输入都有出口”,最怕的是某个分支返回 null 导致主循环空转,日志里看不出问题,实际已经卡死。
4.3 执行层:点击的可靠性与防抖
点击层看着简单,坑不少。dispatchGesture是异步的,连续派发多个手势时系统会排队,如果前一个还没完成就派下一个,可能被丢弃。稳妥做法是维护一个点击队列,或者用回调确认上一个手势完成再发下一个。另外要防抖:同一个按钮在 200ms 内被识别到两次,只点一次。
private var lastTapTime = 0L private var lastTapKey = "" fun safeTap(key: String, x: Float, y: Float) { val now = SystemClock.elapsedRealtime() if (key == lastTapKey && now - lastTapTime < 250) return lastTapKey = key lastTapTime = now tap(x, y) }key用元素名,保证不同按钮互不干扰;250ms 是经验值,按游戏响应速度微调。还有个细节:点击坐标最好落在元素中心附近随机偏移几个像素,固定坐标长期点同一个点,某些游戏的反作弊策略会注意到,虽然 FGA 本身不碰内存,但行为特征太机械也没必要。
5. 避坑与排查:那些跑起来才会暴露的问题
5.1 识别率突然下降
现象:昨天还好好的,今天模板全匹配不上。原因通常是游戏 UI 更新、分辨率变化,或者模板图本身是在不同亮度下截的。解决:先用应用内预览截一张当前画面,手动跑一次匹配看最大响应值,低于阈值就重新截模板图;同时检查屏幕缩放和字体大小设置有没有变,这些都会影响渲染。
5.2 点击无反应
现象:日志显示识别成功、坐标也对,但游戏没反应。原因多半是无障碍配置漏了canPerformGestures,或者点击坐标超出了当前窗口范围(比如游戏是分屏或悬浮窗模式)。解决:确认无障碍 XML 配置,打印实际派发的坐标和屏幕尺寸对比,分屏场景下坐标要基于游戏窗口而非全屏计算。
5.3 运行一段时间后卡死
现象:跑十几分钟后状态机不动了。原因常见是 ImageReader 缓冲区没释放导致采集停摆,或者某个状态没有超时出口。解决:检查每帧image.close()是否执行,给每个状态加超时回退,日志里记录状态迁移时间戳,卡住时能看出停在哪一步。
5.4 电量与发热
现象:手机烫得厉害,电量掉得快。原因:截图频率过高、OpenCV 全屏匹配、识别线程没做节流。解决:降低截图频率(战斗节奏允许的话 2 到 3 帧每秒足够),匹配前裁区域,灰度化,必要时降采样后再匹配,坐标按比例还原。
5.5 换设备后坐标全偏
现象:同一套配置在另一台手机上点错位置。原因:写死了像素坐标或模板按原分辨率匹配。解决:所有区域和坐标用比例存储,运行时乘屏幕宽高;模板按屏幕宽度等比缩放后再匹配,缩放比例记进日志方便核对。
6. 二次开发:换游戏、加逻辑与验证方法
想把这套东西用到别的游戏或加新功能,改动集中在三个地方,按投入产出排序是:模板配置、状态机、识别策略。模板配置最容易,新增元素只加一条TemplateSpec,截好图丢进 assets,调阈值即可。状态机是逻辑核心,换游戏基本要重写迁移规则,但框架不用动——step函数输入当前状态和截图,输出下一状态和动作,这个契约保持住,上层循环原样复用。
加功能时我建议先做“可观测性”再做自动化。具体说,给识别结果和状态迁移打结构化日志,跑一场完整战斗,把日志导出来看每一步的判断依据。很多“玄学”问题,比如偶尔多点一次、偶尔漏一张卡,看日志就能定位是识别误判还是状态竞争。验证新逻辑别直接上正式账号,先用低难度副本跑几十场,统计成功率,低于预期就回放日志找失败样本。
一个具体技巧:把“识别 → 决策 → 执行”做成可录制回放的模式。录下每帧截图和对应动作,离线重跑决策逻辑,不用真机也能验证状态机改动。这招在调阈值和加分支时特别省时间,比每次真机跑一场快得多。我一般会在改状态机前先录三到五场不同情况的战斗作为回归集,改完离线跑一遍,成功率不掉才上真机。
// 回放模式:从录制的帧序列驱动决策,不实际点击 fun replay(frames: List<Pair<Mat, Long>>, engine: BattleEngine) { var state: BattleState = BattleState.InBattle frames.forEach { (mat, ts) -> val (next, action) = engine.step(state, mat) log("t=$ts ${state} -> ${next} action=$action") state = next } }frames里带上时间戳,能还原真实节奏下的状态迁移;engine.step是纯函数,不碰点击,回放安全。这套回放机制搭起来不到一百行,但后面每次改逻辑都能用上。
最后说个教训:我早期图省事,把阈值和坐标直接写在识别代码里,结果换设备调参要改十几处,改漏一处就出现“技能能点、攻击不点”的怪现象。从那以后我每次加新元素都强制走一遍“配置化 + 日志验证 + 回放回归”的流程,宁可前期多花半小时,也不在真机上反复试错。希望帮到你。
本文还有配套的精品资源,点击获取