战斗包子
崩铁自动小助手SRA开发实录

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

  • [[#功能计划|功能计划]]
  • [[#功能实现|功能实现]]
    • [[#功能实现#操作的模拟|操作的模拟]]
    • [[#功能实现#窗口的识别|窗口的识别]]
    • [[#功能实现#GUI与自线程|GUI与自线程]]
    • [[#功能实现#消息窗口|消息窗口]]
  • [[#待实现及不足之处|待实现及不足之处]]
  • [[#开源地址|开源地址]]

崩铁小助手ASR

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

功能计划

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

  • [[#功能计划|功能计划]]
  • [[#功能实现|功能实现]]
    • [[#功能实现#操作的模拟|操作的模拟]]
    • [[#功能实现#窗口的识别|窗口的识别]]
    • [[#功能实现#GUI与自线程|GUI与自线程]]
    • [[#功能实现#消息窗口|消息窗口]]
  • [[#待实现及不足之处|待实现及不足之处]]
  • [[#开源地址|开源地址]]

崩铁小助手ASR

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

功能计划

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

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

经过测试,选择好要刷的本(经验\钱、行迹、突破素材、仪器这些都没问题)后能够自动导航至目标副本,然后识别体力,刷到没体力为止。
演示视频如西瓜视频:从重复劳动中解脱-崩铁自动日常小助手
抖音:从重复劳动中解脱-崩铁自动日常小助手
完整开源代码见 AutoStarRail,欢迎大家star。

功能实现

操作的模拟

操作上使用vgamepad创建虚拟手柄来对游戏进行操作。虽然这样会多一个虚拟设备,但是由于手柄操作时对选中部件的高亮,能够更容易的识别当前选中的东西,并进行精确的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

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识别字符来定位。少部分地方用到了矩形框的识别。

游戏窗口识别

脚本启动时应当先识别当前有没有打开启动器、或游戏,再决定是否需要打开游戏。
对于老版本而言由于启动器和游戏名称都为“崩坏:星穹铁道”,因此无法仅从名称上判断窗口是哪个,还需要进一步判断是启动器还是游戏。可以通过窗口上是否有启动器上独有的字符判断是否为游戏。
因此,窗口检测的流程如下图所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
flowchart TB
检查星穹铁道窗口 -->|有| 存在星穹铁道;
存在星穹铁道 --> 检查特征字符;
检查特征字符-->|有| 当前是旧启动器;
检查特征字符-->|无| 游戏已启动;
当前是旧启动器--> 点击打开游戏;
点击打开游戏--> 游戏已启动;
检查星穹铁道窗口 -->|无| 不存在星穹铁道;
不存在星穹铁道 --> 检查米哈游启动器窗口;
检查米哈游启动器窗口 -->|无| 不存在任何启动器;
不存在任何启动器 --> 搜寻启动器并打开;
搜寻启动器并打开 --> 点击打开游戏;
检查米哈游启动器窗口 -->|有| 启动了米哈游启动器;
启动了米哈游启动器--> 点击打开游戏;

其相关代码在start_game.py中,该部分代码能够实现自动识别当前是否有游戏窗口,如果无窗口则逐步实现打开游戏。

副本导航

该部分代码放置在daily_tasks.py中。
导航的第一步是打开星际和平指南,该步较为简单,直接使用虚拟手柄打开轮盘,然后拨到对应位置即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)

随后需要进一步识别星际和平指南的页面,以及寻找对应的副本。流程如下:

1
2
3
4
5
6
7
8
9
flowchart TB
打开星际和平指南 --> 领取日常奖励;
领取日常奖励 --> 每日实训;
打开星际和平指南 --> 清体力;
清体力 --> 生存索引;
生存索引 --> |经验/武器经验/信用点|拟造花萼金
生存索引 --> |行迹材料|拟造花萼赤
生存索引 --> |突破材料|凝滞虚影
生存索引 --> |遗器|侵蚀隧洞

和平指南页面识别

对于和平指南的页面,如每日实训、生存索引的识别,只需通过ocr识别有无对应字符即可找到该页面

1
2
3
4
5
6
7
8
9
10
11
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为递归寻找,直到满足条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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识别有无目标文字,如果没有,说明当前页面不存在目标标签,需要继续翻页。如果存在,通过灰度阈值识别目标文字所在区域的灰度是否是选中的灰度,如果是则退出寻找,否则继续寻找。

1
2
3
4
5
6
flowchart TB
查询目标文字 --> |无|按下down_button;
按下down_button --> 查询目标文字;
查询目标文字 --> |有|计算目标文字所在区域灰度;
计算目标文字所在区域灰度 --> |范围内| 结束查找;
计算目标文字所在区域灰度 --> |范围外| 按下down_button;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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颜色空间对橙色进行区分的效果并不理想。因此还是采用了矩形边框识别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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案件,当被按下时启动一个子线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

@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.

子线程负责组合之前写好的各种查询、操作的脚本,实现自动化任务的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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

线程之间通过

1
message_signal.emit(str)

传递消息。

消息窗口

消息窗口应当常驻在最上端,然后背景透明,且与鼠标不发生交互,大小可以自动调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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发送至目标邮箱以实现提醒和记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43


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服务器的连接

当前局限

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

开源地址

  • AutoStarRail
    但是为了防止崩铁全屏运行时难以观察运行信息,所以又做了个始终在前台的message窗口,用于实现实时显示自动化脚本的信息。(窗口可放置在任意位置)
  • [[#功能计划|功能计划]]
  • [[#功能实现|功能实现]]
    • [[#功能实现#操作的模拟|操作的模拟]]
    • [[#功能实现#窗口的识别|窗口的识别]]
    • [[#功能实现#GUI与自线程|GUI与自线程]]
    • [[#功能实现#消息窗口|消息窗口]]
  • [[#待实现及不足之处|待实现及不足之处]]
  • [[#开源地址|开源地址]]

崩铁小助手ASR

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

功能计划

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

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

经过测试,选择好要刷的本(经验\钱、行迹、突破素材、仪器这些都没问题)后能够自动导航至目标副本,然后识别体力,刷到没体力为止。
演示视频如西瓜视频:从重复劳动中解脱-崩铁自动日常小助手
抖音:从重复劳动中解脱-崩铁自动日常小助手
完整开源代码见 AutoStarRail,欢迎大家star。

功能实现

操作的模拟

操作上使用vgamepad创建虚拟手柄来对游戏进行操作。虽然这样会多一个虚拟设备,但是由于手柄操作时对选中部件的高亮,能够更容易的识别当前选中的东西,并进行精确的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

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识别字符来定位。少部分地方用到了矩形框的识别。

游戏窗口识别

脚本启动时应当先识别当前有没有打开启动器、或游戏,再决定是否需要打开游戏。
对于老版本而言由于启动器和游戏名称都为“崩坏:星穹铁道”,因此无法仅从名称上判断窗口是哪个,还需要进一步判断是启动器还是游戏。可以通过窗口上是否有启动器上独有的字符判断是否为游戏。
因此,窗口检测的流程如下图所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
flowchart TB
检查星穹铁道窗口 -->|有| 存在星穹铁道;
存在星穹铁道 --> 检查特征字符;
检查特征字符-->|有| 当前是旧启动器;
检查特征字符-->|无| 游戏已启动;
当前是旧启动器--> 点击打开游戏;
点击打开游戏--> 游戏已启动;
检查星穹铁道窗口 -->|无| 不存在星穹铁道;
不存在星穹铁道 --> 检查米哈游启动器窗口;
检查米哈游启动器窗口 -->|无| 不存在任何启动器;
不存在任何启动器 --> 搜寻启动器并打开;
搜寻启动器并打开 --> 点击打开游戏;
检查米哈游启动器窗口 -->|有| 启动了米哈游启动器;
启动了米哈游启动器--> 点击打开游戏;

其相关代码在start_game.py中,该部分代码能够实现自动识别当前是否有游戏窗口,如果无窗口则逐步实现打开游戏。

副本导航

该部分代码放置在daily_tasks.py中。
导航的第一步是打开星际和平指南,该步较为简单,直接使用虚拟手柄打开轮盘,然后拨到对应位置即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)

随后需要进一步识别星际和平指南的页面,以及寻找对应的副本。流程如下:

1
2
3
4
5
6
7
8
9
flowchart TB
打开星际和平指南 --> 领取日常奖励;
领取日常奖励 --> 每日实训;
打开星际和平指南 --> 清体力;
清体力 --> 生存索引;
生存索引 --> |经验/武器经验/信用点|拟造花萼金
生存索引 --> |行迹材料|拟造花萼赤
生存索引 --> |突破材料|凝滞虚影
生存索引 --> |遗器|侵蚀隧洞

和平指南页面识别

对于和平指南的页面,如每日实训、生存索引的识别,只需通过ocr识别有无对应字符即可找到该页面

1
2
3
4
5
6
7
8
9
10
11
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为递归寻找,直到满足条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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识别有无目标文字,如果没有,说明当前页面不存在目标标签,需要继续翻页。如果存在,通过灰度阈值识别目标文字所在区域的灰度是否是选中的灰度,如果是则退出寻找,否则继续寻找。

1
2
3
4
5
6
flowchart TB
查询目标文字 --> |无|按下down_button;
按下down_button --> 查询目标文字;
查询目标文字 --> |有|计算目标文字所在区域灰度;
计算目标文字所在区域灰度 --> |范围内| 结束查找;
计算目标文字所在区域灰度 --> |范围外| 按下down_button;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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颜色空间对橙色进行区分的效果并不理想。因此还是采用了矩形边框识别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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案件,当被按下时启动一个子线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

@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.

子线程负责组合之前写好的各种查询、操作的脚本,实现自动化任务的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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

线程之间通过

1
message_signal.emit(str)

传递消息。

消息窗口

消息窗口应当常驻在最上端,然后背景透明,且与鼠标不发生交互,大小可以自动调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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发送至目标邮箱以实现提醒和记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43


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服务器的连接

当前局限

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

开源地址

功能实现

操作的模拟

操作上使用vgamepad创建虚拟手柄来对游戏进行操作。虽然这样会多一个虚拟设备,但是由于手柄操作时对选中部件的高亮,能够更容易的识别当前选中的东西,并进行精确的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

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识别字符来定位。少部分地方用到了矩形框的识别。

游戏窗口识别

脚本启动时应当先识别当前有没有打开启动器、或游戏,再决定是否需要打开游戏。
对于老版本而言由于启动器和游戏名称都为“崩坏:星穹铁道”,因此无法仅从名称上判断窗口是哪个,还需要进一步判断是启动器还是游戏。可以通过窗口上是否有启动器上独有的字符判断是否为游戏。
因此,窗口检测的流程如下图所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
flowchart TB
检查星穹铁道窗口 -->|有| 存在星穹铁道;
存在星穹铁道 --> 检查特征字符;
检查特征字符-->|有| 当前是旧启动器;
检查特征字符-->|无| 游戏已启动;
当前是旧启动器--> 点击打开游戏;
点击打开游戏--> 游戏已启动;
检查星穹铁道窗口 -->|无| 不存在星穹铁道;
不存在星穹铁道 --> 检查米哈游启动器窗口;
检查米哈游启动器窗口 -->|无| 不存在任何启动器;
不存在任何启动器 --> 搜寻启动器并打开;
搜寻启动器并打开 --> 点击打开游戏;
检查米哈游启动器窗口 -->|有| 启动了米哈游启动器;
启动了米哈游启动器--> 点击打开游戏;

其相关代码在start_game.py中,该部分代码能够实现自动识别当前是否有游戏窗口,如果无窗口则逐步实现打开游戏。

副本导航

该部分代码放置在daily_tasks.py中。
导航的第一步是打开星际和平指南,该步较为简单,直接使用虚拟手柄打开轮盘,然后拨到对应位置即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)

随后需要进一步识别星际和平指南的页面,以及寻找对应的副本。流程如下:

1
2
3
4
5
6
7
8
9
flowchart TB
打开星际和平指南 --> 领取日常奖励;
领取日常奖励 --> 每日实训;
打开星际和平指南 --> 清体力;
清体力 --> 生存索引;
生存索引 --> |经验/武器经验/信用点|拟造花萼金
生存索引 --> |行迹材料|拟造花萼赤
生存索引 --> |突破材料|凝滞虚影
生存索引 --> |遗器|侵蚀隧洞

和平指南页面识别

对于和平指南的页面,如每日实训、生存索引的识别,只需通过ocr识别有无对应字符即可找到该页面

1
2
3
4
5
6
7
8
9
10
11
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为递归寻找,直到满足条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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识别有无目标文字,如果没有,说明当前页面不存在目标标签,需要继续翻页。如果存在,通过灰度阈值识别目标文字所在区域的灰度是否是选中的灰度,如果是则退出寻找,否则继续寻找。

1
2
3
4
5
6
flowchart TB
查询目标文字 --> |无|按下down_button;
按下down_button --> 查询目标文字;
查询目标文字 --> |有|计算目标文字所在区域灰度;
计算目标文字所在区域灰度 --> |范围内| 结束查找;
计算目标文字所在区域灰度 --> |范围外| 按下down_button;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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颜色空间对橙色进行区分的效果并不理想。因此还是采用了矩形边框识别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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案件,当被按下时启动一个子线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

@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.

子线程负责组合之前写好的各种查询、操作的脚本,实现自动化任务的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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

线程之间通过

1
message_signal.emit(str)

传递消息。

消息窗口

消息窗口应当常驻在最上端,然后背景透明,且与鼠标不发生交互,大小可以自动调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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发送至目标邮箱以实现提醒和记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43


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服务器的连接

当前局限

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

开源地址

本文作者:战斗包子
本文链接:https://paipai121.github.io/2024/06/20/日常记录/崩铁自动小助手ASR开发实录/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可