前言
出于对明日方舟的热(嫌)爱(弃)和作为计算机专业学生特有的懒惰性质,我根据参考文章(地址见下文)编写了能在自己电脑上运行的明日方舟脚本。在近一年(其中退坑了半年)的运行和维护中,脚本代码被我不停的修改和维护,成为了全新的一套代码。近日鹰角骚的操作让我对明日方舟失去了肝的动力,但脚本早已完成,思来思去,发现自己没有对脚本的编程过程进行总结。本文章旨在记录写完脚本后对代码的思路分析。第一次写CSDN文章,行文不够熟练,望各位大佬斧正。
脚本制作参考思路:《利用python编写一个pc模拟器明日方舟脚本》
参考思路来源:https://blog.csdn.net/gzt940726/article/details/103836317?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522162904085616780255264434%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=162904085616780255264434&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduend~default-1-103836317.pc_search_result_control_group&utm_term=%E6%98%8E%E6%97%A5%E6%96%B9%E8%88%9F%E8%84%9A%E6%9C%AC&spm=1018.2226.3001.4187
参考文章的编程思路总结
- 将游戏窗口固定在屏幕左上角(0,0),固定游戏窗口大小,根据游戏按钮所在的对应位置进行点击左键操作(使用win32库函数)。
- 识别“源石”图标所在区域,如果该图标被识别成功,则强制退出程序(使用imagehash库函数)。
我对参考程序思路的修改和完善
本脚本是对参考代码三次完善后的产物,此前两次的源代码已被删改,故只展示思路,不展示具体代码。
第一次修改
- 将游戏窗口固定在屏幕左上角(0,0),固定窗口大小。
- 利用imagehash库函数对每一个按键对应区域进行图像识别,利用win32库函数进行左键点击操作,让参考代码中的“盲目点击”变为“看得见的点击”。
- 识别到“源石”图标后不强制退出程序,而是识别并点击取消按钮,并且隔一段时间(设定为半个小时)后再次识别点击开始按钮和对“源石”图标的再一次判断。
- 用户可以提前设定嗑药的开关,程序在“使用药剂”图标显示后可以根据此开关选择嗑药继续还是停止程序和休眠。
第二次重构
- 将代码模块化和封装,增加了“区域”类(当时这个类的类名就叫“区域”),将图像识别操作、图像对应区域坐标、图像点击方法封装在该类中(其实模块化和封装是两个阶段做的事,但描述模块化的伪代码记不得怎么写了,索性合在一起写)
class 区域:
图片路径
区域左上角
区域长
区域高度度
def 找到()
#如果屏幕上指定区域内有这个图标,返回True,否则返回False
def 点击()
#将区域分为外中内三个区域,
#按1:2:7概率比例随机选取一个区域,
#再在这个区域内随机选取一个点进行点击
#主要是忌惮方舟的防脚本系统,
#用了这个复杂但不知道有没有用的算法来安慰自己
- 其实在第一次修改后这个脚本就已经能够稳定使用了,但我又闲着没事尝试了一下碧蓝航线的脚本编写,结果是能用但bug颇多,适用关卡只有3-4(菜的一笔)。在碧蓝航线脚本编写过程中我有了一个疑问:imagehash库函数只能截取(LeftTopX,LeftTopY,RightDownX,RightDownY)区域的图像并和已保存图像做对比,那我是不是把整个屏幕都遍历一遍就可以做到全屏识别了?于是尝试了一波。然而事实是遍历全屏幕速度太慢了,第三次重构前我甚至用了一个晚上遍历保存了六万多张截屏图像和截屏图像与指定图像对比结果(bool),结果是六万多张没有一张与原图像完全一致。
以上是我退坑半年又捡起游戏后探索使用imagehash库函数进行全屏图像识别时在空间的吐槽。
虽然自己写的的全屏图像识别算法究极拉跨,但我仍然坚信一定有能够一秒识别全屏图像的第三方库,在一个小时的高强度搜索后我终于找到了pyautogui库。
远在天边近在眼前了属于是。
第三次重构(详写)
其实pyauto库除了图像识别,也有鼠标点击方法,这波可以直接取代之前使用的imagehash库和win32库,pyauto,永远的神!所以第三次重构围绕着pyauto提供的方法重构了区域类(Area)。
class Area:
##类构造函数和成员变量的get和set方法
def __init__(self,Name='None', LeftTopX=0,LeftTopY=0,Width=0, Height=0,FilePath=None):
self.m_Name=Name
self.m_LeftTopX=LeftTopX
self.m_LeftTopY=LeftTopY
self.m_Width=Width
self.m_Height=Height
self.m_FilePath=FilePath
def getName(self):
return self.m_Name
def getLeftTop(self):
return self.m_LeftTopX, self.m_LeftTopY
def getRightDown(self):
return self.m_LeftTopX+self.m_Width,self.m_LeftTopY+self.m_Height
def getWidth(self):
return self.m_Width
def getHeight(self):
return self.m_Height
def getFilePath(self):
return self.m_FilePath
def setName(self, Name):
self.m_Name=Name
def setLeftTop(self, X,Y):
self.m_LeftTopX=X
self.m_LeftTopY=Y
def setWidth(self, Width):
self.m_Width=Width
def setHeight(self, Height):
self.m_Height=Height
def setFilePath(self, FilePath):
self.m_FilePath=FilePath
##如果屏幕上存在对应图片,返回True,否则返回False
def Find(self,confidence=0.9):
try:
point=auto.locateOnScreen(self.m_FilePath,confidence=0.9)
if(point!=None):
print("在",point,"找到",self.getName())
self.m_LeftTopX,self.m_LeftTopY,self.m_Width,self.m_Height=point
return True
else:
print("未找到",self.getName(),end="\r")
return False
except:
input("未找到文件或文件存在中文路径:"+self.m_FilePath+"程序大概率无法继续执行,且在报错后闪退,回车继续")
def Click(self):
##如果没有找到图像就不点击,返回False
if self.Find()==False:
return False
CenterArea=Area( self.getName()+"中框",\
self.getLeftTop()[0]+(self.getWidth()/4),\
self.getLeftTop()[1]+(self.getHeight()/4),\
self.getWidth()/2,\
self.getHeight()/2)
interiorArea=Area(self.getName()+"内框",\
CenterArea.getLeftTop()[0]+(CenterArea.getWidth()/4),\
CenterArea.getLeftTop()[1]+(CenterArea.getHeight()/4),\
CenterArea.getWidth()/2,\
CenterArea.getHeight()/2)
choose=random.randint(1,9)
#choose=8
if(1<=choose and choose<=2):
point=(random.randint( int(self.getLeftTop()[0]),\
int(self.getRightDown()[0])),\
random.randint( int(self.getLeftTop()[1]),\
int(self.getRightDown()[1])))
else:
if(3<=choose and choose<=5):
point=(random.randint( int(CenterArea.getLeftTop()[0]),\
int(CenterArea.getRightDown()[0])),\
random.randint( int(CenterArea.getLeftTop()[1]),\
int(CenterArea.getRightDown()[1])))
else:
if(6<=choose and choose<=9):
point=(random.randint( int(interiorArea.getLeftTop()[0]),\
int(interiorArea.getRightDown()[0])),\
random.randint( int(interiorArea.getLeftTop()[1]),\
int(interiorArea.getRightDown()[1])))
#win32api.SetCursorPos([point[0],point[1]])
#win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP | win32con.MOUSEEVENTF_LEFTDOWN, 0, 0)
pyautogui.click(x=point[0],y=point[1],button='left')
#pyautogui.moveTo(11,55)
print("点击",self.getName(),"X=",point[0]," Y=",point[1])
return point
接下来需要各个按键的对应截图,
使用pyautogui.screenshot(FilePath,region=(X,Y,Width,Height))函数可以截取指定区域的图片。
为了规范变量名我还跑去国际服研究了一下一些专有名词的拼写方式(PRTS是我自己取的,原本的单词我觉得莫得灵魂)。
然后是程序执行的详细流程。
- 找到模拟器窗口,设置模拟器大小
该流程使用的是参考代码中原封不动的函数,这段我仅仅能看懂,把两个分开的方法合并成一个嵌套方法并做一些简单的变量修改,要是让我全部重构还是有些许困难
def setWindowsSize(OrderWindow):#OrderWindow为要被修改的窗口
def foo(hwnd,mouse):
#去掉下面这句就所有都输出了,但是我不需要那么多
if IsWindow(hwnd) and IsWindowEnabled(hwnd) and IsWindowVisible(hwnd):
titles.add(GetWindowText(hwnd))
global titles
EnumWindows(foo, 0)
lt = [t for t in titles if t]
lt.sort()
for t in lt:
#print(t)
if(t.find(OrderWindow.getName())) >= 0:
hwnd = win32gui.FindWindow(None, t)
print(hwnd)
win32gui.SetWindowPos(\
hwnd, win32con.HWND_TOPMOST,\
OrderWindow.getLeftTop()[0],OrderWindow.getLeftTop()[1],\
OrderWindow.getWidth(),OrderWindow.getHeight(),\
win32con.SWP_SHOWWINDOW)#修改窗口属性核心代码
print(OrderWindow.getLeftTop()[0],OrderWindow.getLeftTop()[1],\
OrderWindow.getWidth(),OrderWindow.getHeight())
hwnd=win32gui.FindWindow(None, t)
print(hwnd)
size = win32gui.GetWindowRect(hwnd)
print(size)
return size
在这一环节中会出现一些重要的问题:窗口名应该叫什么?窗口应该设置多大?
这些问题源于用户使用的安卓模拟器并不都是同一款。比如我自己用的是mumu模拟器,而我朋友使用的则是雷电模拟器,因此可以先拿这两款模拟器进行比较。
此为mumu模拟器的标题名:“明日方舟 - 星云引擎
此为雷电模拟器的标题名:“雷电模拟器”
两款模拟器的标题名都不相同,且没有共同点,因此理所当然会想到让用户自己去设置这个窗口名。而如果通过程序输入来获取标题名,那便会让用户每次使用程序都要输入一次窗口名,因此也理所当然会想到使用文本IO操作来读取用户自定义的窗口名。
然后再考虑一下窗口大小的细节。
以上为雷电模拟器和mumu模拟器窗口大小相同时的状态1024x588,
其中雷电模拟器实际游戏画面大小为981x553,
而mumu模拟器实际游戏画面大小为888x499,
由此可见由于各个模拟器布局方式的不同,即使将窗口设置为同一大小,实际的游戏画面大小也并不相同。然而pyauto的图像识别功能只能识别到和保存图片大小完全一致的图片,这便要求运行游戏时的游戏画面大小必须和编程时截取游戏按钮图片时的游戏画面大小完全一致。而对与这一点我所能想到的解决方法便是由编程人员(我自己)或者用户自行确认和记录当游戏画面大小为981x553时所用模拟器的窗口大小。不过和窗口名的思路一致,文件读取操作必不可少。因此对配置文件的读取、异常处理、给用户的编辑配置文件方法说明都要安排上。
def FileInit(OrderWindow):
while True:
try:
f = open('set.txt', 'r',encoding='utf-8')
print("已打开文件")
break
except:
print("读取文件失败,已创建新文件set.txt")
f = open('set.txt', 'w')
f.close()
print("配置写入说明:")
print("第一行:模拟器进程名")
print("第二行:游戏窗口宽度x")
print("第三行:游戏窗口高度y")
print("注:程序默认读取utf-8编码txt文件")
input("请填入配置信息保存后回车继续")
try:
OrderWindow.setName(f.readline().strip())
print("读取窗口名成功")
OrderWindow.setLeftTop(0,0)
OrderWindow.setWidth(int(f.readline()))
print("读取窗口宽度x成功")
OrderWindow.setHeight(int(f.readline()))
print("读取窗口高度y成功")
f.close()
except:
input("读取文件数据失败,可能因为文件内容缺少或不匹配,请检查文件内容,回车后结束程序")
exit(0)
print("窗口名:"+OrderWindow.getName())
print("宽度=",OrderWindow.getWidth(),",高度=",OrderWindow.getHeight())
- 展示菜单,不必多说。
def Menu():
EmergencySanitySwitch=False#碎药开关初始化
AnnihilationSwitch=False#剿灭开关初始化
while True:
Mode=-1#模式选择初始化
print("1.肝任意关卡")
print("2.肝剿灭")
if EmergencySanitySwitch==True:
print("3.自动消耗药剂开关: True 选择则关闭")
if EmergencySanitySwitch==False:
print("3.自动消耗药剂开关: False 选择则开启")
Mode=eval(input("请选择模式(输入Q或q退出程序):"))
if Mode==1:
AnnihilationSwitch=False#剿灭开关关闭
SetWork(EmergencySanitySwitch,AnnihilationSwitch,999)#设置并开始运行程序核心内容
os.system('cls')
if Mode==2:
AnnihilationSwitch=True#剿灭开关打开
AnnihilationCount=int(input("请选择执行剿灭次数:"))
SetWork(EmergencySanitySwitch,AnnihilationSwitch,AnnihilationCount)#设置并开始运行程序核心内容
os.system('cls')
if Mode==3:
if EmergencySanitySwitch==True:
EmergencySanitySwitch=False
else:
EmergencySanitySwitch=True
os.system('cls')
if Mode=='Q'or Mode=='q':
break
程序菜单
3. 由流程图可以看见,程序在设置信息都获取完毕后便会开始正式工作。此处使用模块化的函数来实现每一个流程。
使用pyautogui.moveTo()时需要注意,如果将鼠标移动到屏幕的边界,可能会引发FailSafeException异常:
PyAutoGUI fail-safe triggered from mouse moving to a corner of the screen.
百度上说添加pyautogui.FAILSAFE = True即可解决,但我的解决方案是不把鼠标移到边界,移动到不影响识别的位置就够了。
由于游戏中出现等级提升的机会过于稀少,因此并没有机会来测试对“LevelUp”图标进行识别的代码是否存在bug。
#用于处理Start按钮判断的流程模块
def ReadyStart():
Start_G=Area("Start_G",FilePath="picture\\Start_G_798,514,158,37.jpg")
Start_B=Area("Start_B",FilePath="picture\\Start_B_798,514,158,37.jpg")
PRTS_OFF=Area("PRTS_OFF",FilePath="picture\\PRTS_OFF_802,471,155,34.jpg")
PRTS_ON=Area("PRTS_ON",FilePath="picture\\PRTS_ON_802,471,155,34.jpg")
PRTS_LOCK=Area("PRTS_LOCK",FilePath="picture\\PRTS_LOCK_802,471,155,34.jpg")
while PRTS_ON.Find()==False:#如果代理未开启
if PRTS_OFF.Find()==True:#如果代理可以被开启
print("\r检测到未开启代理,自动开启代理")
PRTS_OFF.Click()
pyautogui.moveTo(11,55)
if PRTS_LOCK.Find()==True:#如果代理不能被开启
input("检测到该关卡自动代理未解锁,按下回车键六秒后重试")
time.sleep(6)
while Start_G.Click()==False and Start_B.Click()==False:#确认代理开启,点击蓝色Start_G
time.sleep(6)#如果没找到就一直循环
pyautogui.moveTo(11,55)#防止鼠标在检测区域影响判断
time.sleep(10)#找到点击后等待
#用于处理MissionStart按钮判断的流程模块
def ReadyMissionStart():
MissionStart=Area("MissionStart",FilePath="picture\\MissionStart_795,314,105,216.jpg")
while MissionStart.Find()==False:#没有找到就多找几次
print("\r请确认红色开始行动按键在预定范围,6秒后将重试")
time.sleep(6)
while MissionStart.Click()!=False:#找到了并且点击了,如果后面找不到就算点击成功
time.sleep(6)
#用于处理MissionResults界面判断的流程模块
def ReadyMissionResults():
GetEXP=Area("GetEXP",FilePath="picture\\GetEXP_396,453,66,47.jpg")
Results=Area("Results",FilePath="picture\\Results_254,427,40,20.jpg")
LevelUp=Area("LevelUp",FilePath="picture\\LevelUp_814,272,164,95.jpg")
while GetEXP.Find()==False:
LevelUp.Click()#如果没有找到经验结算,寻找并点击经验提升,有则点击,不需要返回值
time.sleep(15)#每隔15秒检测一次
pyautogui.moveTo(11,55)
else:
Results.Click()#点击随意位置
return
#用于处理剿灭Results界面判断的流程模块
def ReadyAnnilationResults():
GetEXP=Area("GetEXP",FilePath="picture\\GetEXP_396,453,66,47.jpg")
AnnihilationOver=Area("AnnihilationOver",FilePath="picture\\AnnihilationOver_84,338,101,41.jpg")
LevelUp=Area("LevelUp",FilePath="picture\\LevelUp_814,272,164,95.jpg")
while AnnihilationOver.Find()==False:
time.sleep(15)#每隔15秒检测一次
pyautogui.moveTo(11,55)
else:
point=AnnihilationOver.Click()
time.sleep(6)
while GetEXP.Find()==False:
LevelUp.Click()#如果没有找到经验结算,寻找并点击经验提升,有则点击,不需要返回值
time.sleep(15)#每隔15秒检测一次
pyautogui.moveTo(11,55)
else:
pyautogui.click(point)#由于剿灭没有不透明标识符,因此只能保存刚刚点过的坐标再点一次
print("点击",point)
return
- 使用函数将上面的几个流程串联起来。
#将ReadyStart()、ReadyMissionStart()、ReadyMissionResults()、ReadyAnnilationResults()串联起来,添加药剂、源石判断处理
def StartWork(EmergencySanitySwitch,AnnihilationSwitch,AnnilationCount):#剿灭用计次,用True标记系统暂停,False标记系统结束
Cancel=Area("Cancel",FilePath="picture\\Cancel_583,462,33,33.jpg")
Confirm=Area("Confirm",FilePath="picture\\Confirm_820,462,33,33.jpg")
UseEmergencySanity=Area("UseEmergencySanity",FilePath="picture\\UseEmergencySanity_468,94,267,90.jpg")
UseOriginite=Area("UseOriginite",FilePath="picture\\UseOriginite_468,133,308,378.jpg")
#1:正常返回,等待下次循环 2:理智不足,等待30分钟后循环3:达成目标,不再循环
ReadyStart()
#win32api.SetCursorPos([0,0])
if UseOriginite.Find():#出现嗑源石提示
Cancel.Click()#点击取消,没有可选动作
print("\r检测到碎石界面,系统暂停,将在30分钟(5点理智)后继续",)
return 2,AnnilationCount
if UseEmergencySanity.Find():#出现嗑药提示
if EmergencySanitySwitch==True:
print("允许自动消耗药剂,将消耗一次药剂")
Confirm.Click()#允许嗑药,点击确认
time.sleep(6)
ReadyStart()#回到了蓝色行动页面需要再点一次蓝色开始行动
else:
Cancel.Click()#不允许嗑药,点击取消
print("未允许自动消耗药剂,系统暂停,将在30分钟(5点理智)后继续")
return 2,AnnilationCount
ReadyMissionStart()
if AnnihilationSwitch==True:
ReadyAnnilationResults()#剿灭结束画面
AnnilationCount=AnnilationCount-1
print("剿灭剩余次数:",AnnilationCount,"次")
if AnnilationCount<=0:
input("剿灭次数已达到指定次数,请回车继续")
return 3,AnnilationCount
else:
ReadyMissionResults()#普通结束画面
time.sleep(6)#确保完全退回结算界面
return 1,AnnilationCount
一次StartWork为执行一次关卡,使用SetWork对多次StartWork进行管理。
因为方舟每日凌晨4点进行刷新,如果上一次执行关卡在3点,而将要在4点执行下一次关卡,则停止程序,等待用户手动处理游戏刷新(领签到、基建换班之类的),由于我凌晨三四点还醒着的机会不多,这部分也基本没有进行测试。
#管理StartWork的大循环
def SetWork(EmergencySanitySwitch,AnnihilationSwitch,AnnilationCount):
print("将在凌晨4点自动停止工作")
input("请手动转到关卡的蓝色\"开始行动\"界面后回车")
os.system('cls')
StartWorkSign=1
Time = datetime.datetime.now()
BeforeTime=Time
while (Time.hour!=4 or BeforeTime.hour!=3):##上一次执行和这次执行不能跨过4点,且执行不返回3
BeforeTime=Time
StartWorkSign,AnnilationCount=StartWork(EmergencySanitySwitch,AnnihilationSwitch,AnnilationCount)
Time = datetime.datetime.now()
print("当前时间:",Time.year,Time.month,Time.day,Time.hour,Time.minute,Time.second,sep='-')
if StartWorkSign==1 :
continue
if StartWorkSign==2:
BeforeTime=Time
SleepTimes=30*60#休眠的秒数
while SleepTimes>0:
print("剩余时间:",int(SleepTimes/60),"分",int(SleepTimes%60),"秒",end='\r')
SleepTimes=SleepTimes-1
time.sleep(1)
print("计时结束,开始重启,正在检查当前时间")
Time = datetime.datetime.now()
if StartWorkSign==3:
print("模式已结束")
input("回车刷新以上记录")
break
Time = datetime.datetime.now()
- 最后将所有的函数串联在main函数里。由于程序会修改窗口名与配置文件中窗口名相同的窗口,因此如果程序窗口的名字与配置文件冲突了,程序有可能会修改自己的窗口而不是模拟器的窗口,因此程序窗口名必须独一无二。
if __name__ == '__main__':
os.system('title 没⇝有⇝力↝量↺')
OrderWindow=Area()
FileInit(OrderWindow)
SelfWindow=Area("没⇝有⇝力↝量↺",OrderWindow.getRightDown()[0],OrderWindow.getLeftTop()[1],300,300)
titles = set()
setWindowsSize(OrderWindow)#32,483 309,549 617,496
setWindowsSize(SelfWindow)
Menu()
结尾
本文章只是记录自己对程序复盘的思路,并未发布图片素材,因此源程序并不可用。如果有人需要可在评论区留言,我会把代码和图片打包传到云盘。
当前程序最大的缺点便是不能跨夜运行,不过我确实想不到有什么方案能够让程序自动帮我安排基建。虽然用这个程序能让我一整天不需要去肝方舟,但如果我外出,家里没有人,电脑开一整天说不定会引发安全事故。因此下一步打算尝试在Android平台编写一套方舟脚本。希望到我学会Android开发前还没有对明日方舟和鹰角失去兴趣。最后说一句yjfm。