崩铁自动小助手ASR开发实录

崩铁小助手ASR

天下苦二游上班坐牢久矣。方舟有MAA造福大众,免去日常之苦,能让我专心于关卡,但是米家游戏就不行了,于是就有了这个崩铁小助手——AutoStarRail的想法。

功能计划

目前初步计划就是能够实现每天自动清体力,领日常奖励,让我不用操心每天还得上线清体力的事情。最后实现的界面如下,大概和方舟的maa差不多。
![[Pasted image 20240621134224.png]]

但是为了防止崩铁全屏运行时难以观察运行信息,所以又做了个始终在前台的message窗口,用于实现实时显示自动化脚本的信息。(窗口可放置在任意位置)
![[Pasted image 20240621134258.png]]

经过测试,选择好要刷的本(经验\钱、行迹、突破素材、仪器这些都没问题)后能够自动导航至目标副本,然后识别体力,刷到没体力为止。
演示视频如西瓜视频:从重复劳动中解脱-崩铁自动日常小助手
抖音:从重复劳动中解脱-崩铁自动日常小助手
完整开源代码见 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识别有无目标文字,如果没有,说明当前页面不存在目标标签,需要继续翻页。如果存在,通过灰度阈值识别目标文字所在区域的灰度是否是选中的灰度,如果是则退出寻找,否则继续寻找。

范围内
范围外
查询目标文字
按下down_button
计算目标文字所在区域灰度
结束查找
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服务器的连接

当前局限

现在的版本要求必须先打开好自动战斗并沿用自动战斗,否则脚本不会自动开启自动战斗,而是静待。
无战斗失败的异常处理。
无法设置是否吃燃料。
模拟宇宙功能未实现。

开源地址

  • 10
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

豆沙粽子好吃嘛!

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值