崩铁小助手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
GUI采用pyqt6实现。主要包括一个主窗口和一个消息窗口
子线程
在MainWindow中设置一个start案件,当被按下时启动一个子线程
@pyqtSlot()
def on_start_button_clicked(self):
'''
开始执行相关任务
'''
if self.worker_thread and self.worker_thread.isRunning():
self.ui.start_button.setEnabled(False)
self.ui.start_button.setText("Start")
self.task_worker.stop()
self.stop_task_signal.emit() # 发出停止信号
self.worker_thread.quit()
self.worker_thread.wait()
self.worker_thread = None
self.ui.start_button.setEnabled(True)
else:
self.get_task_list() # 获取任务列表
self.ui.start_button.setText("Stop")
self.worker_thread = QThread()
self.task_worker = TaskWorker(self.to_do,self.get_farm())
self.task_worker.moveToThread(self.worker_thread)
self.task_worker.message_signal.connect(self.append_message) # 连接消息信号
self.task_worker.finished_signal.connect(self.on_thread_finished)
self.task_worker.finished_signal.connect(self.worker_thread.quit) # ?
# self.task_worker.stop_signal.connect(self.task_worker.stop) # 新增:连接停止信号到stop方法
self.worker_thread.started.connect(self.task_worker.run)
self.worker_thread.finished.connect(lambda: setattr(self, 'worker_thread', None))
# self.worker_thread.started.connect(lambda: self.stop_task_signal.connect(self.task_worker.stop)) # 启动线程后建立连接
# self.worker_thread.finished.connect(lambda: self.stop_task_signal.disconnect(self.task_worker.stop)) # 线程结束后断开连接
self.worker_thread.start()
## 邮件发送
if self.ui.enable_email.isChecked():
# 接受邮件发送
sender = Email_sender(self.email,self.password,pigeon=self.append_message)
sender.send_email(self.ui.text_display.toPlainText())
@pyqtSlot()
def on_set_email_clicked(self):
self.email = self.ui.sender_email_edit.text()
self.password = self.ui.sender_password_edit.text()
self.email_server = self.ui.sender_email_server.text()
self.email_port = self.ui.sender_SSL_port.text()
with open('./config/credentials.txt', 'w') as file:
file.write(self.email)
file.write('\n')
file.write(self.password)
file.write('\n')
file.write(self.email_server)
file.write('\n')
file.write(self.email_port)
file.write('\n')
if self.ui.enable_email.isChecked():
file.write("1")
else:
file.write("0")
# if self.ui.
子线程负责组合之前写好的各种查询、操作的脚本,实现自动化任务的操作
class TaskWorker(QObject):
message_signal = pyqtSignal(str) # Signal for sending messages to the GUI
finished_signal = pyqtSignal() # Signal indicating the task is finished
def undefined(self):
self.message_signal.emit("开发中")
def __init__(self,task_list = [], farm_info = None):
super().__init__()
self._stop_requested = False
self.task_list = task_list
self.game_starter = StartGame(pigeon = self.message_signal.emit)
self.daily_tasker = DailyTask(gw.getWindowsWithTitle("崩坏:星穹铁道")[0],pigeon = self.message_signal.emit)
self.farm_info = farm_info
# self.mainWindow = mainWindow # 为了获取窗口中的状态
# self.mainWindow.materials_box_1
def task_dispatcher(self,task_list):
"""
根据任务列表调度执行任务
参数:
tasks (list): 一个包含任务名称的列表,如 ['刷体力', '领取日常奖励']
"""
task_functions = {
'刷体力': self.daily_tasker.clean_stamina, # brush_energy,
'领取日常奖励': self.daily_tasker.daily_task, # claim_daily_reward,
'领取纪行奖励': self.daily_tasker.get_nameless_honor, # claim_chronicle_reward,
'模拟宇宙': self.undefined # simulate_universe,
}
for task in task_list:
if task in task_functions:
self.message_signal.emit("Task: " + task)
if task == "刷体力":
self.daily_tasker.clean_stamina(self.farm_info)
else:
task_function = task_functions[task]
task_function()
time.sleep(5)
self.message_signal.emit("Task: " + task + " complete")
else:
self.message_signal.emit(f"未知任务: {task}, 跳过执行.")
def run(self):
self.message_signal.emit("Tasks: "+str(self.task_list))
# 如果tasks不为空,应当先检查是否打开了游戏
if self.task_list:
self.message_signal.emit("Checking if game is open...")
self.game_starter.start_game()
self.task_dispatcher(self.task_list)
def stop(self):
self._stop_requested = True
线程之间通过
message_signal.emit(str)
传递消息。
消息窗口
消息窗口应当常驻在最上端,然后背景透明,且与鼠标不发生交互,大小可以自动调整
class MessageBox(QLabel):
def __init__(self, text = "", parent=None):
super().__init__(text, parent)
self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.FramelessWindowHint)
self.setStyleSheet("background-color: rgba(10, 10, 10, 128); color: red;")
self.setGeometry(0, 0, 100, 30) # 设置初始位置和大小
self.set_font_size(20)
self.adjustSizeToContent()
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
def set_font_size(self, size):
font = self.font()
font.setPointSize(size)
self.setFont(font)
def set_text(self, text):
self.setText(text)
self.adjustSizeToContent()
def show(self):
super().show()
# self.move(0, 0) # 每次显示时都移动到左上角
screen = QApplication.primaryScreen()
screen_geometry = screen.geometry()
window_geometry = self.frameGeometry()
x = screen_geometry.width() - window_geometry.width()
self.move(x, 500) # 每次显示时都移动到右上角
def resizeEvent(self, event):
super().resizeEvent(event)
# self.move(0, 0) # 当窗口大小改变时,也移动到左上角
screen = QApplication.primaryScreen()
screen_geometry = screen.geometry()
window_geometry = self.frameGeometry()
x = screen_geometry.width() - window_geometry.width()
self.move(x, 500) # 当窗口大小改变时,也移动到右上角
def adjustSizeToContent(self):
font_metrics = QFontMetrics(self.font())
lines = self.text().splitlines()
text_width = 0
for line in lines:
text_width = max(text_width,font_metrics.horizontalAdvance(line))
text_height = font_metrics.height() * len(lines) # 计算所有文本行的高度
self.setFixedSize(text_width, text_height)
def eventFilter(self, obj, event):
if event.type() in (Qt.EventType.MouseButtonPress, Qt.EventType.MouseButtonRelease, Qt.EventType.MouseButtonDblClick, Qt.EventType.MouseMove):
# 将鼠标事件传递到下层的窗口
return False
return super().eventFilter(obj, event)
def mousePressEvent(self, event):
# 不要处理鼠标点击事件,使其传递到下层窗口
event.ignore()
邮件通知
本助手添加了邮件通知功能,待自动化任务执行完成后将log发送至目标邮箱以实现提醒和记录。
class Email_sender:
def __init__(self,username = "",password = "",server = 'smtp.163.com',port = 465, pigeon = print) -> None:
# 网易邮箱的SMTP服务器地址和端口
self.smtp_server = server
self.smtp_port = port # 网易邮箱SMTP服务端口通常为465
# # 发件人邮箱账号和授权码
# username = 'your_email@163.com'
# password = 'your_authorization_code' # 这里填写授权码,而不是密码
# 邮箱
self.username = username
# self.receiver = username
self.password = password
self.pigeon = pigeon
# 邮件主题和正文
# self.subject = '自动星铁'
# self.body = '这是一封测试邮件。'
def set_email(self,username,password):
# 邮箱
self.username = username
# self.receiver = username
self.password = password
def send_email(self,body):
# 创建一个MIMEText邮件对象
message = MIMEText(body, 'plain', 'utf-8')
message['From'] = Header(self.username, 'utf-8')
message['To'] = Header(self.username)
message['Subject'] = Header("自动星铁message", 'utf-8')
try:
# 连接SMTP服务器
server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port)
server.login(self.username, self.password) # 登录SMTP服务器
server.sendmail(self.username, self.username, message.as_string()) # 发送邮件
self.pigeon("邮件发送成功")
except smtplib.SMTPException as e:
self.pigeon("Error: 无法发送邮件", e)
finally:
server.quit() # 断开与SMTP服务器的连接
当前局限
现在的版本要求必须先打开好自动战斗并沿用自动战斗,否则脚本不会自动开启自动战斗,而是静待。
无战斗失败的异常处理。
无法设置是否吃燃料。
模拟宇宙功能未实现。