崩铁小助手ASR
天下苦二游上班坐牢久矣。方舟有MAA造福大众,免去日常之苦,能让我专心于关卡,但是米家游戏就不行了,于是就有了这个崩铁小助手——AutoStarRail的想法。
功能计划
目前初步计划就是能够实现每天自动清体力,领日常奖励,让我不用操心每天还得上线清体力的事情。最后实现的界面如下,大概和方舟的maa差不多。
但是为了防止崩铁全屏运行时难以观察运行信息,所以又做了个始终在前台的message窗口,用于实现实时显示自动化脚本的信息。(窗口可放置在任意位置)
经过测试,选择好要刷的本(经验\钱、行迹、突破素材、仪器这些都没问题)后能够自动导航至目标副本,然后识别体力,刷到没体力为止。
演示视频如西瓜视频:从重复劳动中解脱-崩铁自动日常小助手
抖音:从重复劳动中解脱-崩铁自动日常小助手
完整开源代码见 AutoStarRail,欢迎大家star。
功能实现
操作的模拟
操作上使用vgamepad创建虚拟手柄来对游戏进行操作。虽然这样会多一个虚拟设备,但是由于手柄操作时对选中部件的高亮,能够更容易的识别当前选中的东西,并进行精确的操作。
class Gamepad:
def __init__(self,pigeon = None):
# 初始化一个手柄
self.pigeon = pigeon
self.gamepad = vgamepad.VX360Gamepad()
# 初始化手柄状态
self.reset_gamepad()
def reset_gamepad(self):
self.gamepad.reset()#键位扳机摇杆全部重置成初始状态
self.gamepad.update()
# gamepad 操作
def click_button(self,button,duration=0.15):
self.gamepad.press_button(button)
self.gamepad.update()
time.sleep(duration + random.randint(0,int(0.05*100))/100)
self.gamepad.release_button(button)
self.gamepad.update()
if self.pigeon:
self.pigeon("click " + button_mapping[button])
def press_button(self,button):
self.gamepad.press_button(button)
self.gamepad.update()
if self.pigeon:
self.pigeon("press " + button_mapping[button])
def release_button(self,button):
self.gamepad.release_button(button)
self.gamepad.update()
def LEFT_TRIGGER(self,value):
self.gamepad.left_trigger_float(value)
# 左扳机轴 value改成0.0到1.0之间的浮点值,可以精确到小数点后5位
self.gamepad.update()
def RIGHT_TRIGGER(self,value):
self.gamepad.right_trigger_float(value)
# 右扳机轴 value改成0.0到1.0之间的浮点值,可以精确到小数点后5位
self.gamepad.update()
def LEFT_JOYSTICK(self,theta,amplitude,ran_theta = 2*np.pi/25,ran_amp = 1/25): #x_value, y_value):
theta = theta + random.randint(0,ran_theta*100)/100
amplitude = amplitude + random.randint(0,ran_amp*100)/100
x_value = 1.414*amplitude * np.cos(theta)
y_value = 1.414*amplitude * np.sin(theta)
self.gamepad.left_joystick_float(x_value, y_value)
# 左摇杆XY轴 x_values和y_values改成-1.0到1.0之间的浮点值,可以精确到小数点后5位
self.gamepad.update()
if self.pigeon:
self.pigeon("" + "Left joystick")
def RIGHT_JOYSTCIK(self,theta,amplitude,ran_theta = 2*np.pi/25,ran_amp = 1/25):
theta = theta + random.randint(0,int(ran_theta*100))/100
amplitude = amplitude + random.randint(0,int(ran_amp*100))/100
x_value = amplitude * np.cos(theta)
y_value = amplitude * np.sin(theta)
self.gamepad.right_joystick_float(x_value, y_value)
# 右摇杆XY轴 x_values和y_values改成-1.0到1.0之间的浮点值,可以精确到小数点后5位
self.gamepad.update()
if self.pigeon:
self.pigeon("" + "Left joystick")
def joystick_movement(self, theta=0, duration=0.5, amplitude=1):
start_time = time.time()
while time.time() - start_time < duration:
# 时间-角度序列
theta_time = theta * (time.time() - start_time) / duration
# 幅度
amplitude_time = amplitude * (time.time() - start_time) / duration
self.RIGHT_JOYSTCIK(theta_time, amplitude_time)
time.sleep(0.01)
窗口的识别
这部分涉及到图像的一些识别。为了减少计算资源的消耗,本文主要使用paddleocr识别字符来定位。少部分地方用到了矩形框的识别。
游戏窗口识别
脚本启动时应当先识别当前有没有打开启动器、或游戏,再决定是否需要打开游戏。
对于老版本而言由于启动器和游戏名称都为“崩坏:星穹铁道”,因此无法仅从名称上判断窗口是哪个,还需要进一步判断是启动器还是游戏。可以通过窗口上是否有启动器上独有的字符判断是否为游戏。
因此,窗口检测的流程如下图所示。
其相关代码在start_game.py中,该部分代码能够实现自动识别当前是否有游戏窗口,如果无窗口则逐步实现打开游戏。
副本导航
该部分代码放置在daily_tasks.py中。
导航的第一步是打开星际和平指南,该步较为简单,直接使用虚拟手柄打开轮盘,然后拨到对应位置即可。
def open_star_guide(self):
# 打开星际和平指南
self.gp.press_button(LEFT_SHOULDER)
self.gp.joystick_movement(np.pi * 1 / 4, duration= 1) # 移动到指南
time.sleep(0.8)
self.gp.joystick_movement(amplitude=0)
self.gp.release_button(LEFT_SHOULDER)
self.pigeon("打开星际和平指南")
time.sleep(0.9)
随后需要进一步识别星际和平指南的页面,以及寻找对应的副本。流程如下:
和平指南页面识别
对于和平指南的页面,如每日实训、生存索引的识别,只需通过ocr识别有无对应字符即可找到该页面
def find_page(self,tag = "生存索引"):
# 已经打开星际和平指南后,通过手柄切换标签找到对应的page
if TargetDetector(self.window,self.gp).search_button(tag, RIGHT_SHOULDER):
self.pigeon("找到" + tag)
else:
self.pigeon("未找到" + tag)
其中TargetDetect中的search_button为递归寻找,直到满足条件。
def search_button(self, btn_text, action):
"""
查找相应按钮
:param btn_text: 目标按钮名称
:param action: 找不到按钮对应操作
:return:
"""
figure, _, _, _, _ = self.win_action.get_screenshot(self.window)
find, _ = self.findText(figure,btn_text)
if find:
print(f'找到{btn_text}')
return True
else:
self.gp.click_button(action)
time.sleep(0.2 + random.randint(0, 10) / 100)
return self.search_button(btn_text, action)
页面中高亮位置的寻找
在确定找到页面后,我们需要识别出当前高亮的标签(如拟造花萼、侵蚀隧洞)是哪个。
此处我们可以先使用OCR识别有无目标文字,如果没有,说明当前页面不存在目标标签,需要继续翻页。如果存在,通过灰度阈值识别目标文字所在区域的灰度是否是选中的灰度,如果是则退出寻找,否则继续寻找。
def find_highlight(self, btn_text, action=None):
"""
寻找按钮的高亮状态。
通过截图并查找按钮文本,判断按钮是否处于高亮状态。如果按钮未高亮,则点击按钮并重试。
主要用于自动化测试中对按钮状态的判断和操作。
参数:
btn_text (str): 按钮的文本内容,用于查找按钮。
action (function, optional): 当按钮未高亮时执行的操作,默认为None。可以是一个函数,该函数会在按钮未高亮时被调用。
返回:
bool: 如果按钮处于高亮状态,则返回True;否则返回False。
"""
# 截取窗口的屏幕快照
# 转到灰度上看灰度值。先截取字附近的区域
figure, _, _, _, _ = self.win_action.get_screenshot(self.window) # 在屏幕快照中查找按钮文本
find, pos = self.findText(figure, btn_text)
# 如果按钮未找到
if not find: # 没找到
self.gp.click_button(action)
time.sleep(0.2 + random.randint(0, 10) / 100)
return self.find_highlight(btn_text, action)
ave_gray = self.get_average_gray_value(figure,pos)
if ave_gray < 100: # 高亮-黑色
print(f'找到{btn_text}')
return True
else:
self.gp.click_button(action)
time.sleep(0.2 + random.randint(0, 10) / 100)
return self.find_highlight(btn_text, action)
右侧具体副本的寻找
使用手柄的话,右侧选择的副本会有一个橙色的矩形框作为高亮,因此识别矩形框就知道我们当前选中的是哪个了。
使用HSV颜色空间对橙色进行区分的效果并不理想。因此还是采用了矩形边框识别。
def detect_dungeon_boxes(self,image,text):
# 可以找出当前高亮的选择区域
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, threshold1=100, threshold2=200)
contours, _ = cv2.findContours(edges.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
min_area_threshold = 10 # 设定最小面积阈值
height, width = image.shape[:2] # 获取图像高度和宽度
area_ratio = 0.2
target_area = height * width * area_ratio # 计算目标最大面积
max_area = 0
for contour in contours:
area = cv2.contourArea(contour)
if area > max_area and area < target_area:
max_area = area
max_contour = contour
for contour in contours:
area = cv2.contourArea(contour)
if area > min_area_threshold:
# 计算边界框
x, y, w, h = cv2.boundingRect(contour)
# 绘制矩形
cv2.rectangle(image, (x, y), (x+w, y+h), (0, 255, 0), 2)
# 如果找到了符合条件的轮廓
if max_contour is not None:
# 获取边界框
x, y, w, h = cv2.boundingRect(max_contour)
# 截取该矩形区域
cropped_image = image[y:y+h, x:x+w]
find, pos = self.findText(cropped_image,text)
# 显示截取的图像
# cv2.imshow('Cropped Rectangle', cropped_image)
# cv2.waitKey(0)
# cv2.destroyAllWindows()
return find
else:
# print("Not found.")
return False
def find_dungeon(self,btn_text, action = None):
figure, _, _, _, _ = self.win_action.get_screenshot(self.window)
# self.gp.click_button(action)
if self.detect_dungeon_boxes(figure,btn_text):
print('已选中:' + btn_text)
else:
self.gp.click_button(action)
time.sleep(0.2 + random.randint(0, 10) / 100)
return self.find_dungeon(btn_text, action)
未完待续
GUI、消息窗口、邮件等功能以及当前版本未实现的功能待后续更新。