feat(task): 封装滚动处理 & 优化部分任务的滚动逻辑

This commit is contained in:
XcantloadX 2025-03-28 14:44:06 +08:00
parent 1805ff719d
commit c315408e8b
4 changed files with 334 additions and 29 deletions

View File

@ -3,6 +3,7 @@ import logging
from .. import R
from ..common import conf
from ..game_ui.scrollable import Scrollable
from ..actions.scenes import at_home, goto_home
from kotonebot.backend.image import TemplateMatchResult
from kotonebot import task, action, device, image, sleep, Interval
@ -106,15 +107,8 @@ def capsule_toys():
draw_capsule_toys(buttons[1], conf().capsule_toys.sense_capsule_toys_count)
# 划到第二页
device.swipe(
R.Daily.CapsuleToys.NextPageStartPoint.x,
R.Daily.CapsuleToys.NextPageStartPoint.y,
R.Daily.CapsuleToys.NextPageEndPoint.x,
R.Daily.CapsuleToys.NextPageEndPoint.y,
duration=2.0 # 划慢点,确保精确定位
# FIXME: adb不支持swipe duration失效
)
sleep(1) # 等待滑动静止由于swipe duration失效所以这里需要手动等待
sc = Scrollable()
sc.next(page=1)
# 处理逻辑扭蛋扭蛋和非凡扭蛋
buttons = get_capsule_toys_draw_buttons()
@ -128,8 +122,4 @@ def capsule_toys():
draw_capsule_toys(buttons[1], conf().capsule_toys.anomaly_capsule_toys_count)
if __name__ == '__main__':
import logging
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
logger.setLevel(logging.DEBUG)
capsule_toys()
capsule_toys()

View File

@ -3,6 +3,7 @@ import logging
from .. import R
from ..common import conf
from ..game_ui.scrollable import Scrollable
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, sleep
@ -31,19 +32,8 @@ def upgrade_support_card():
device.click(image.expect_wait(R.Common.ButtonIdolSupportCard, timeout=5))
sleep(2)
# TODO: 将下面硬编码的值塞到合适的地方
# 往下滑,划到最底部
for _ in range(5):
device.swipe(
R.Daily.SupportCard.DragDownStartPoint.x,
R.Daily.SupportCard.DragDownStartPoint.y,
R.Daily.SupportCard.DragDownEndPoint.x,
R.Daily.SupportCard.DragDownEndPoint.y,
duration=1.0
)
sleep(0.1)
sleep(1.5)
Scrollable().to(1)
# 点击左上角第一张支援卡
# 点击位置百分比: (0.18, 0.34)

View File

@ -0,0 +1,323 @@
import logging
import time
from typing import Literal
import cv2
import numpy as np
from cv2.typing import MatLike, Rect
from kotonebot import device, color, action
from kotonebot.backend.core import HintBox
from kotonebot.backend.preprocessor import HsvColorFilter
logger = logging.getLogger(__name__)
# 暗色系滚动条阈值。bitwise_not = True
# 例:金币商店、金币扭蛋页面
THRESHOLD_DARK_FULL = 240 # 滚动条+滚动条背景
THRESHOLD_DARK_FOREGROUND = 190 # 仅滚动条
# 亮色系滚动条阈值。bitwise_not = False
# 例:每日任务、音乐播放器选歌页面
THRESHOLD_LIGHT_FULL = 140 # 滚动条+滚动条背景(效果不佳)
THRESHOLD_LIGHT_FOREGROUND = 220 # 仅滚动条
def find_scroll_bar(img: MatLike, threshold: int, bitwise_not: bool = False) -> Rect | None:
"""
寻找给定图像中的滚动条
基于二值化+轮廓查找实现
:param img: 输入图像图像必须中存在滚动条否则无法保证结果是什么
:param threshold: 二值化阈值
:param bitwise_not: 是否对二值化结果取反
:return: 滚动条的矩形区域 `(x, y, w, h)`如果未找到则返回 None
"""
# 灰度、二值化、查找轮廓
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)
if bitwise_not:
binary = cv2.bitwise_not(binary)
# cv2.imshow('binary', binary)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 找出所有可能是滚动条的轮廓:
# 宽高比 < 0.5,且形似矩形
filtered_contours = []
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
contour_area = cv2.contourArea(contour)
rect_area = w * h
if w/h < 0.5 and contour_area / rect_area > 0.6:
filtered_contours.append((contour, (x, y, w, h)))
# 找出最长的轮廓
if filtered_contours:
longest_contour = max(filtered_contours, key=lambda c: c[1][3])
return longest_contour[1]
return None
def find_scroll_bar2(img: MatLike) -> Rect | None:
"""
寻找给定图像中的滚动条
基于边缘检测+轮廓查找实现
:param img: 输入图像图像必须中存在滚动条否则无法保证结果是什么
:return: 滚动条的矩形区域 `(x, y, w, h)`如果未找到则返回 None
"""
# 高斯模糊、边缘检测
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(gray, 50, 70)
# cv2.imshow('edges', cv2.resize(edges, (0, 0), fx=0.5, fy=0.5))
# 膨胀
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
dilated = cv2.dilate(edges, kernel, iterations=1)
# cv2.imshow('dilated', cv2.resize(dilated, (0, 0), fx=0.5, fy=0.5))
# 轮廓检测
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 找出最可能是滚动条的轮廓:
# 宽高比 < 0.5,且形似矩形,且最长
rects = []
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
contour_area = cv2.contourArea(contour)
rect_area = w * h
if w/h < 0.5 and contour_area / rect_area > 0.6:
rects.append((x, y, w, h))
if rects:
longest_rect = max(rects, key=lambda r: r[2] * r[3])
return longest_rect
return None
class ScrollableIterator:
def __init__(self, scrollable: 'Scrollable', delta_pixels: int):
self.scrollable = scrollable
self.delta_pixels = delta_pixels
def __iter__(self):
return self
def __next__(self):
if self.scrollable.position >= 1:
raise StopIteration
self.scrollable.by(pixels=self.delta_pixels)
return self.scrollable.position
class Scrollable:
"""
此类用于处理游戏内的可滚动容器
```python
sc = Scrollable()
sc.to(0) # 滚动到最开始
sc.to(0.5) # 滚动到中间
sc.to(1) # 滚动到最后
sc.by(0.1) # 滚动10%
sc.by(pixels=100) # 滚动100px
sc.page_count # 滚动页数
sc.position # 当前滚动位置
# 以步长 10% 开始滚动,直到滚动到最后
for _ in sc(0.1):
print(sc.position)
```
"""
def __init__(
self,
scrollbar_rect: HintBox | None = None,
color_schema: Literal['light', 'dark'] = 'light',
*,
at_start_threshold: float = 0.01,
at_end_threshold: float = 0.99,
auto_update: bool = True
):
"""
:param auto_update: 在每次滑动后是否自动更新滚动数据
"""
self.color_schema = color_schema
self.scrollbar_rect = scrollbar_rect
self.position: float = 0
"""当前滚动位置。范围 [0, 1]"""
self.thumb_height: int | None = None
"""滚动条把手高度"""
self.thumb_position: tuple[int, int] | None = None
"""滚动条把手位置"""
self.track_position: tuple[int, int] | None = None
"""滚动轨道位置"""
self.track_height: int | None = None
"""滚动轨道高度"""
self.page_count: int | None = None
"""滚动页数"""
self.auto_update = auto_update
"""是否自动更新滚动数据"""
self.at_start_threshold = at_start_threshold
self.at_end_threshold = at_end_threshold
if color_schema == 'dark':
raise NotImplementedError('Dark color schema is not implemented yet.')
@action('滚动.更新数据', screenshot_mode='manual-inherit')
def update(self) -> bool:
"""
立即更新滚动数据
:return: 是否更新成功
"""
img = device.screenshot()
if self.scrollbar_rect is None:
logger.debug('Finding scrollbar rect...')
self.scrollbar_rect = find_scroll_bar2(img)
if self.scrollbar_rect is None:
logger.warning('Unable to find scrollbar. (1)')
return False
logger.debug('Scrollbar rect found.')
x, y, w, h = self.scrollbar_rect
scroll_img = img[y:y+h, x:x+w]
# 灰度、二值化
gray = cv2.cvtColor(scroll_img, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY)
# 0 = 滚动条255 = 背景
# 计算滚动位置
positions = np.where(binary == 0)[0]
if len(positions) > 0:
self.track_position = (int(x), int(y))
self.track_height = int(h)
self.thumb_height = int(positions[-1] - positions[0])
self.thumb_position = (int(x), int(y + positions[0]))
self.position = float(positions[-1] / h)
self.page_count = int(h / self.thumb_height)
logger.debug(f'Scrollbar height: {self.thumb_height}, position: {self.position}')
if self.position < self.at_start_threshold:
self.position = 0
elif self.position > self.at_end_threshold:
self.position = 1
return True
else:
logger.warning('Unable to find scrollbar. (2)')
return False
@action('滚动.下一页', screenshot_mode='manual-inherit')
def next(self, *, page: float) -> bool:
"""
滚动到下一页
:param page: 滚动页数
:return: 是否滚动成功
"""
logger.debug('Scrolling to next page.')
if not self.thumb_height:
self.update()
if not self.thumb_height or not self.thumb_position:
logger.warning('Unable to update scrollbar data.')
return False
if self.position >= 1:
logger.debug('Already at the end of the scrollbar.')
return False
delta = int(self.thumb_height * page)
self.by(pixels=delta)
return True
@action('滚动.滚动', screenshot_mode='manual-inherit')
def by(self, percentage: float | None = None, *, pixels: int | None = None) -> bool:
"""
滚动指定距离
:param percentage: 滚动距离范围 [-1, 1]
:param pixels: 滚动距离单位为像素此参数优先级高于 percentage
:return: 是否滚动成功
"""
if percentage is not None and (percentage > 1 or percentage < -1):
raise ValueError('percentage must be in range [-1, 1].')
if pixels is not None and pixels < 0:
raise ValueError('pixels must be positive.')
if not self.thumb_height or not self.thumb_position or not self.track_height:
self.update()
if not self.thumb_height or not self.thumb_position or not self.track_height:
logger.warning('Unable to update scrollbar data.')
return False
x, src_y = self.thumb_position
src_y += self.thumb_height // 2
if pixels is not None:
dst_y = src_y + pixels
logger.debug(f'Scrolling by {pixels} px...')
elif percentage is not None:
logger.debug(f'Scrolling by {percentage}...')
dst_y = src_y + int(self.track_height * percentage)
else:
raise ValueError('Either percentage or pixels must be provided.')
device.swipe(x, src_y, x, dst_y, 0.3)
time.sleep(0.2)
if self.auto_update:
self.update()
return True
@action('滚动.滚动到', screenshot_mode='manual-inherit')
def to(self, position: float) -> bool:
"""
滚动到指定位置
:param position: 目标位置范围 [0, 1]
:return: 是否滚动成功
"""
if position > 1 or position < 0:
raise ValueError('position must be in range [0, 1].')
logger.debug(f'Scrolling to {position}...')
if not self.thumb_height or not self.thumb_position or not self.track_height or not self.track_position:
self.update()
if not self.thumb_height or not self.thumb_position or not self.track_height or not self.track_position:
logger.warning('Unable to update scrollbar data.')
return False
x, y = self.track_position
tx, ty = self.thumb_position
ty += self.thumb_height // 2
target_y = y + int(self.track_height * position)
device.swipe(tx, ty, x, target_y, 0.3)
time.sleep(0.2)
if self.auto_update:
self.update()
return True
def __call__(self, step_percentage: float) -> ScrollableIterator:
"""
以指定步长滚动
:param step_percentage: 步长范围 [-1, 1]
:return: 一个迭代器迭代时滚动指定步长
"""
if not self.track_height:
self.update()
if not self.track_height:
raise ValueError('Unable to update scrollbar data.')
return ScrollableIterator(self, int(self.track_height * step_percentage))
if __name__ == '__main__':
from kotonebot.backend.context import init_context, manual_context, device
init_context()
manual_context().begin()
from kotonebot import device
import cv2
from kotonebot.util import cv2_imread
import time
logger.setLevel(logging.DEBUG)
device.screenshot()
sc = Scrollable(color_schema='light')
sc.update()
sc.to(0)
print(sc.page_count)
pg = sc.page_count
assert pg is not None
for _ in sc(4 / (pg * 12) * 0.8):
print(sc.position)
cv2.waitKey(0)
cv2.destroyAllWindows()

View File

@ -3,6 +3,7 @@ from itertools import cycle
from typing import Optional, Literal
from kotonebot.backend.context.context import wait
from kotonebot.tasks.game_ui.scrollable import Scrollable
from kotonebot.ui import user
from kotonebot.util import Countdown, Interval
from kotonebot.backend.dispatch import SimpleDispatcher
@ -70,7 +71,7 @@ def select_idol(target_titles: list[str] | PIdol):
found = False
max_tries = 5
tries = 0
# TODO: 加入 ScrollBar 类,判断滚动条进度
sc = Scrollable()
# 找到目标偶像
while not found:
# 首先检查当前选中的是不是已经是目标
@ -90,8 +91,9 @@ def select_idol(target_titles: list[str] | PIdol):
if tries > max_tries:
break
# 翻页
device.swipe(x1=100, x2=100, y1=max_y, y2=min_y)
sleep(2)
# device.swipe(x1=100, x2=100, y1=max_y, y2=min_y)
sc.next(page=0.8)
sleep(0.4)
device.screenshot()
results = image.find_all(R.Produce.IconPIdolLevel)
results.sort(key=lambda r: tuple(r.position))