在维护了关于碧蓝档案自动化项目 [BAAH] 一年之余,权当是纪念这个项目的 1.x 版本,以及总结并分享目前整个项目设计以及部分功能上的不足(后续不知道有没有机会去重构得到 2.x 版本),写了这篇文章。如果后续有要做游戏自动化脚本有缘人,不妨参照一二。
”bug越多,迭代越多;迭代越多,修复越多;修复越多,bug越少。所以,bug越多,bug越少“ —— 沃·斯基硕德
起因
BAAuto 项目是我真正使用自动化工具的起因,虽然之前也有听过ALAS,MAA等大名,但是毕竟不玩这俩游戏,所以没咋接触过。BAAuto项目虽然下下来跑是跑通的,但是在功能上只能说是勉强能满足要求,所以才有了自己实现一个的想法。不过自己实现并维护后发现确实还蛮复杂的(
一些思考
思考一:功能细节:有时不要考虑顺序
往往设计自动化步骤的时候,会不小心按照实际操作的顺序去设计自动化步骤,比如 在关卡列表页面需要点击按钮1进入对战页面,点击按钮2开始对战,对战结束后需要等待按钮3出现,点击按钮3回到列表页面。
很多时候我们会这么写
# 步骤1
while 1:
if 匹配到 按钮1:
点击 按钮1
break
# 步骤2
while 1:
if 匹配到 按钮2:
点击 按钮2
break
# 步骤3
while 1:
if 匹配到 按钮3:
点击 按钮3
break
但是如果我们不需要区分其中的每一个步骤并作出反应(比如说记录战斗时的敌方等级),我们可以直接在一个步骤里同时处理这三个按钮;我们只是想要 完成一场战斗:也就是从关卡列表页面,进入战斗,最后再次回到列表页面。如下:
while 1:
if 匹配到 按钮1:
点击 按钮1
if 匹配到 按钮2:
点击 按钮2
if 匹配到 按钮3:
点击 按钮3
break
虽然初看上面两段代码,只是有些许的区别。但是一旦原始的线性流程变多,出现更多的分支,采用并行判断的方法能够显著减少该流程的复杂度并提升项目后期的可维护性。
思考二:功能细节:时刻记得维护状态
通常自动化脚本的各项任务都是以模块化进行的,我们会将任务分成任务A,任务B,任务C...这些任务之间既有平级关系,也会有包含关系:在执行一个大任务的内部,我们可能会根据情况执行若干个不同的小项任务。此时,维护任务的状态就显得尤为重要。一个任务是否被成功完成,是否被跳过,是否发生了错误...这些状态最好都在任务这个模块进行管辖。
比如以模块化执行推图(将游戏关卡向前推进)任务时,内部需要执行若干次交战任务(FightQuest),大概的类层级如下:
class ExploreQuest:
def run(self):
for ...:
FightQuest().run()
# 检查任务结束情况
check_task()
这种方法虽然可以,但是会导致模块内聚性不够。在FightQuest执行任务过程中更容易检测交战任务的执行状态,(或者说,直到交战任务结算后,退出FightQuest()模块的时候,才会回到ExploreQuest模块内,此时检查交战结果的任务不应该交给ExploreQuest模块,而是交给FightQuest模块以达成高内聚)
为每个module添加Status常量或在全局变量中维护该任务status,使其在执行结束后能及时更新任务执行结果状态。如
class ExploreQuest:
def run(self):
for ...:
fq_status = FightQuest().run()
# 检查任务结束情况
check_task_status(fq_status)
思考三:项目设计:功能模块分层
通过加层可以解决几乎所有低内聚问题。
截图功能可以使用adb截图,也可以使用minicap。如果本来只写了一个screenshot方法:
def screenshot(self):
"""通过adb截图, 并返回截图数据"""
img_data = subprocess.run(["adb", "screencap", ...]).stdout
return img_data
现在,如果随着项目的发展,我们要添加minicap截图方法。
def screenshot(self):
"""截图并返回截图数据"""
if self.config.screenshot == "adb":
img_data = subprocess.run(["adb", "screencap", ...]).stdout
elif self.config.screenshot == "minicap":
img_data = minicap.screenshot()
else:
...
return img_data
此时screenshot的任务其实就从一个比较底层的功能升级成了一个较高一层的功能方法。如果后续项目又要加一个功能:每次截图的时候识别屏幕上是否有loading标识,如果有则点击。这个时候,我们如果继续改变screenshot这个方法:
def screenshot(self):
"""截图并返回截图数据"""
if self.config.screenshot == "adb":
img_data = subprocess.run(["adb", "screencap", ...]).stdout
elif self.config.screenshot == "minicap":
img_data = minicap.screenshot()
else:
...
# 识别loading
if 匹配到loading:
点击loading
return img_data
这样就会使整个screenshot的功能更加低内聚,如何改变使设计更高内聚呢 --> 分层
功能层 - 工具层 - 底层方法层
那在上面这个情况中,我们需要对当前的screenshot进行功能拆分以高内聚,该如何划分出来新的层呢:
从已有的方法中拆分新的层时,往下进行拆分。
也就是说,我们将原本的screenshot这个方法名作为功能层,我们往下拆分出screenshot_util,adb_screenshot/minicap_screenshot。
尽可能保留screenshot的名字不要变,往下进行拆分。避免调用screenshot方产生错误 (这也同时意味着尽可能保留screenshot默认入参或新增表现与以往相同的默认参数)。
思考四:项目设计:asserts资源管理
自动任务脚本的资源是依附于游戏的组件(或者说游戏截图)的,使用opencv图像匹配的脚本往往需要对游戏UI的更新作出及时的响应。此外,往往一款游戏会有多语言,导致不同服务器之间的UI(文字)不同,造成需要管理的资源变多。
但好在不同服务器之间共享同一套游戏流程,正如上文所说,加层可以解决所有低内聚问题,我们在做多服多语言的资源管理的时候,搞不定就加层!
无论是按钮/弹窗图片,还是像素点的颜色/位置,我们都用一个asserts对象将其封装起来,在脚本控制层只考虑应该点击什么,交由下层进行实际点击资源的判断,这点和上文中的screenshot异曲同工。
但是,还是会有不同asserts文件摆放位置的不同。比如,国际服有按钮a,按钮b。欧服有Button a,Button b。我们可以按照两种策略对文件进行分类。
一种是基于服务器类型的文件夹分割:
- asserts
- Global
- 按钮a.png
- 按钮b.png
- 欧服
- 按钮a.png
- 按钮b.png
一种是基于资源的文件夹分割
- asserts
- 按钮a
- __init__.py
- Global.png
- 欧服.png
- 按钮b
- __init__.py
- Global.png
- 欧服.png
目前个人维护的脚本项目是使用的前一种资源分割,也比较像常规分包的方式。但是经过比较长时间的维护之后,逐渐推崇第二种方式。个人觉得其优点有二:
更好进行参数语义化,在文件夹下直接放置 __init__.py,配合asserts下的__init__.py的包导入。即可实现比较顺畅的资源参数语义化。
发生资源变更时,更容易找到其他服的资源。当某个按钮变更时(比如从扫荡raid变换为快速战斗fast fight),由于不同服的同一个按钮文件处在同一个文件夹下,十分容易找到这些按钮图片,进行资源的更新。
目前能想到的需要改进之处就是如此,后面有灵感了再写写~