feat(task): 支持培育中四五张卡片情况的检测

This commit is contained in:
XcantloadX 2025-04-14 16:36:51 +08:00
parent e63e0de631
commit 0ddcb423a5
4 changed files with 417 additions and 424 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

View File

@ -0,0 +1,381 @@
import logging
from functools import partial
from typing import Callable, NamedTuple, Literal
import cv2
import numpy as np
from cv2.typing import MatLike
from .. import R
from ..common import conf
from ..game_ui import dialog
from ..util.trace import trace
from kotonebot.util import crop
from kotonebot import action, Interval, Countdown, device, image, sleep, ocr, contains, use_screenshot, color, Rect
class SkillCard(NamedTuple):
available: bool
rect: Rect
class CardPosInfo(NamedTuple):
x: int
y: int
w: int
h: int
type: Literal[0, 1, 2, 3, 4, 10] # 0~4=卡片下标10=SKIP
class CardDetectResult(NamedTuple):
type: int
"""
点击的卡片类型
0=第一张卡片1=第二张卡片2=第三张卡片3=第四张卡片10=SKIP
"""
score: float
"""总分数"""
left_score: float
"""左边分数"""
right_score: float
"""右边分数"""
top_score: float
"""上边分数"""
bottom_score: float
"""下边分数"""
rect: Rect
logger = logging.getLogger(__name__)
# TODO: 硬编码。需要想办法移动到资源文件中
CARD_SIZE = (192, 252) # 卡片大小 w, h
CARD_Y = 883 # 卡片 Y 坐标
# 只有一张卡的情况
CARD_START_X_1 = 264
CARD_DELTA_X_1 = 0
# 两张卡的情况
CARD_START_X_2 = 156 # 第一张卡的起始 X
CARD_DELTA_X_2 = 24 # 间隔距离 = card_x_2 - card_x_1 - card_width
# 三张卡的情况
CARD_START_X_3 = 47
CARD_DELTA_X_3 = 25
# 四张卡的情况
CARD_START_X_4 = 17
CARD_DELTA_X_4 = -27
# 五张卡的情况
CARD_START_X_5 = 17
CARD_DELTA_X_5 = -68
# SKIP 按钮
SKIP_CARD_BUTTON = CardPosInfo(621, 739, 85, 85, 10)
def calc_card_position(card_count: int):
w, h = CARD_SIZE
if card_count == 1:
result = [
CardPosInfo(x=CARD_START_X_1, y=CARD_Y, w=w, h=h, type=0)
]
elif card_count == 2:
delta = CARD_DELTA_X_2 + w
result = [
CardPosInfo(x=CARD_START_X_2, y=CARD_Y, w=w, h=h, type=0),
CardPosInfo(x=CARD_START_X_2 + delta, y=CARD_Y, w=w, h=h, type=1),
]
elif card_count == 3:
delta = CARD_DELTA_X_3 + w
result = [
CardPosInfo(x=CARD_START_X_3, y=CARD_Y, w=w, h=h, type=0),
CardPosInfo(x=CARD_START_X_3 + delta, y=CARD_Y, w=w, h=h, type=1),
CardPosInfo(x=CARD_START_X_3 + delta * 2, y=CARD_Y, w=w, h=h, type=2),
]
elif card_count == 4:
delta = CARD_DELTA_X_4 + w
result = [
CardPosInfo(x=CARD_START_X_4, y=CARD_Y, w=w, h=h, type=0),
CardPosInfo(x=CARD_START_X_4 + delta, y=CARD_Y, w=w, h=h, type=1),
CardPosInfo(x=CARD_START_X_4 + delta * 2, y=CARD_Y, w=w, h=h, type=2),
CardPosInfo(x=CARD_START_X_4 + delta * 3, y=CARD_Y, w=w, h=h, type=3),
]
elif card_count == 5:
delta = CARD_DELTA_X_5 + w
result = [
CardPosInfo(x=CARD_START_X_5, y=CARD_Y, w=w, h=h, type=0),
CardPosInfo(x=CARD_START_X_5 + delta, y=CARD_Y, w=w, h=h, type=1),
CardPosInfo(x=CARD_START_X_5 + delta * 2, y=CARD_Y, w=w, h=h, type=2),
CardPosInfo(x=CARD_START_X_5 + delta * 3, y=CARD_Y, w=w, h=h, type=3),
CardPosInfo(x=CARD_START_X_5 + delta * 4, y=CARD_Y, w=w, h=h, type=4),
]
else:
raise ValueError(f'不支持 {card_count} 张手牌')
return result
@action('打牌', screenshot_mode='manual')
def do_cards(
threshold_predicate: Callable[[int, CardDetectResult], bool],
end_predicate: Callable[[], bool]
):
"""
循环打出推荐卡直到考试/练习结束
前置条件考试/练习页面\n
结束状态考试/练习结束的一瞬间
:param threshold_predicate: 推荐卡检测阈值判断函数
:param end_predicate: 结束条件判断函数
"""
it = Interval(seconds=1/30)
timeout_cd = Countdown(sec=120).start() # 推荐卡检测超时计时器
break_cd = Countdown(sec=3).start() # 满足结束条件计时器
no_card_cd = Countdown(sec=4) # 无手牌计时器
detect_card_count_cd = Countdown(sec=4).start() # 刷新检测手牌数量间隔
tries = 1
card_count = -1
while True:
device.click(0, 0)
img = device.screenshot()
it.wait()
# 技能卡自选移动对话框
if image.find(R.InPurodyuusu.IconTitleSkillCardMove):
if handle_skill_card_move():
sleep(4) # 等待卡片刷新
continue
# 技能卡效果无法发动对话框
if image.find(R.Common.ButtonIconCheckMark):
logger.info("Confirmation dialog detected")
device.click()
sleep(4) # 等待卡片刷新
continue
# 更新卡片数量
if card_count == -1 or detect_card_count_cd.expired():
detect_card_count_cd.reset()
card_count = skill_card_count(img)
logger.debug("Current card count: %d", card_count)
# 处理手牌
if card_count == 0:
# 处理本回合已无剩余手牌的情况
# TODO: 使用模板匹配而不是 OCR提升速度
no_card_cd.start()
no_remaining_card = ocr.find(contains("0枚"), rect=R.InPurodyuusu.BoxNoSkillCard)
if no_remaining_card and no_card_cd.expired():
logger.debug('No remaining card detected. Skip this turn.')
# TODO: HARD CODEDED
SKIP_POSITION = (621, 739, 85, 85)
device.click(SKIP_POSITION)
no_card_cd.reset()
continue
else:
if handle_recommended_card(
card_count=card_count,
threshold_predicate=threshold_predicate,
img=img
):
logger.info("Handle recommended card success with %d tries", tries)
sleep(4.5)
tries = 0
timeout_cd.reset()
continue
else:
tries += 1
# 检测超时(防止一直卡在检测)
if timeout_cd.expired():
logger.info("Recommend card detection timed out. Click first card.")
card_rects = calc_card_position(card_count)
card_rect = card_rects[0]
device.double_click(card_rect[:4])
timeout_cd.reset()
# 结束条件
if card_count == 0 and end_predicate():
if not break_cd.started:
break_cd.start()
if break_cd.expired():
break
else:
break_cd.reset()
logger.info("CLEAR/PERFECT not found. Practice finished.")
@action("技能卡移动")
def handle_skill_card_move():
"""
前置条件技能卡移动对话框\n
结束状态对话框结束瞬间
"""
cards = image.find_all_multi([
R.InPurodyuusu.A,
R.InPurodyuusu.M
])
if not cards:
logger.info("No skill cards found")
return False
it = Interval()
cd = Countdown(sec=3)
while True:
device.screenshot()
# 判断对话框是否关闭
# 已关闭,开始计时
if not image.find(R.InPurodyuusu.IconTitleSkillCardMove):
cd.start()
if cd.expired():
logger.info("Skill card move dialog closed.")
break
# 没有,要继续选择并确定
else:
cd.reset()
card = cards.pop()
device.double_click(card)
sleep(1)
dialog.yes()
it.wait()
logger.debug("Handle skill card move finished.")
@action('获取当前卡牌信息', screenshot_mode='manual-inherit')
def obtain_cards(img: MatLike | None = None):
img = use_screenshot(img)
cards_rects = image.find_all_multi([
R.InPurodyuusu.A,
R.InPurodyuusu.M,
R.InPurodyuusu.T
])
logger.info("Current cards: %s", len(cards_rects))
cards = []
for result in cards_rects:
available = color.find('#7a7d7d', rect=result.rect) is None
cards.append(SkillCard(available=available, rect=result.rect))
return cards
def handle_recommended_card(
card_count: int, timeout: float = 7,
threshold_predicate: Callable[[int, CardDetectResult], bool] = lambda _, __: True,
*,
img: MatLike | None = None,
):
result = detect_recommended_card(card_count, threshold_predicate, img=img)
if result is not None:
device.double_click(result)
return result
return None
@action('获取当前卡片数量', screenshot_mode='manual-inherit')
def skill_card_count(img: MatLike | None = None):
"""获取当前持有的技能卡数量"""
img = use_screenshot(img)
img = crop(img, y1=0.83, y2=0.90)
count = image.raw().count(img, R.InPurodyuusu.A)
count += image.raw().count(img, R.InPurodyuusu.M)
count += image.raw().count(img, R.InPurodyuusu.T)
logger.info("Current skill card count: %d", count)
return count
def detect_recommended_card(
card_count: int,
threshold_predicate: Callable[[int, CardDetectResult], bool],
*,
img: MatLike | None = None,
):
"""
识别推荐卡片
前置条件练习或考试中\n
结束状态-
:param card_count: 卡片数量(2-4)
:param threshold_predicate: 阈值判断函数
:return: 执行结果若返回 None表示未识别到推荐卡片
"""
YELLOW_LOWER = np.array([20, 100, 100])
YELLOW_UPPER = np.array([30, 255, 255])
GLOW_EXTENSION = 15
cards = calc_card_position(card_count)
cards.append(SKIP_CARD_BUTTON)
img = use_screenshot(img)
original_image = img.copy()
results: list[CardDetectResult] = []
for x, y, w, h, return_value in cards:
outer = (max(0, x - GLOW_EXTENSION), max(0, y - GLOW_EXTENSION))
# 裁剪出检测区域
glow_area = img[outer[1]:y + h + GLOW_EXTENSION, outer[0]:x + w + GLOW_EXTENSION]
area_h = glow_area.shape[0]
area_w = glow_area.shape[1]
glow_area[GLOW_EXTENSION:area_h-GLOW_EXTENSION, GLOW_EXTENSION:area_w-GLOW_EXTENSION] = 0
# 过滤出目标黄色
glow_area = cv2.cvtColor(glow_area, cv2.COLOR_BGR2HSV)
yellow_mask = cv2.inRange(glow_area, YELLOW_LOWER, YELLOW_UPPER)
# 分割出每一边
left_border = yellow_mask[:, 0:GLOW_EXTENSION]
right_border = yellow_mask[:, area_w-GLOW_EXTENSION:area_w]
top_border = yellow_mask[0:GLOW_EXTENSION, :]
bottom_border = yellow_mask[area_h-GLOW_EXTENSION:area_h, :]
y_border_pixels = area_h * GLOW_EXTENSION
x_border_pixels = area_w * GLOW_EXTENSION
# 计算每一边的分数
left_score = np.count_nonzero(left_border) / y_border_pixels
right_score = np.count_nonzero(right_border) / y_border_pixels
top_score = np.count_nonzero(top_border) / x_border_pixels
bottom_score = np.count_nonzero(bottom_border) / x_border_pixels
result = (left_score + right_score + top_score + bottom_score) / 4
results.append(CardDetectResult(
return_value,
result,
left_score,
right_score,
top_score,
bottom_score,
(x, y, w, h)
))
img = original_image.copy()
# cv2.imshow(f"card detect {return_value}", cv2.cvtColor(glow_area, cv2.COLOR_HSV2BGR))
# cv2.namedWindow(f"card detect {return_value}", cv2.WINDOW_NORMAL)
# cv2.moveWindow(f"card detect {return_value}", 100 + (return_value % 3) * 300, 100 + (return_value // 3) * 300)
# cv2.waitKey(1)
filtered_results = list(filter(partial(threshold_predicate, card_count), results))
if not filtered_results:
max_result = max(results, key=lambda x: x.score)
logger.info("Max card detect result (discarded): value=%d score=%.4f borders=(%.4f, %.4f, %.4f, %.4f)",
max_result.type,
max_result.score,
max_result.left_score,
max_result.right_score,
max_result.top_score,
max_result.bottom_score
)
return None
filtered_results.sort(key=lambda x: x.score, reverse=True)
logger.info("Max card detect result: value=%d score=%.4f borders=(%.4f, %.4f, %.4f, %.4f)",
filtered_results[0].type,
filtered_results[0].score,
filtered_results[0].left_score,
filtered_results[0].right_score,
filtered_results[0].top_score,
filtered_results[0].bottom_score
)
# 跟踪检测结果
if conf().trace.recommend_card_detection:
x, y, w, h = filtered_results[0].rect
cv2.rectangle(original_image, (x, y), (x+w, y+h), (0, 0, 255), 3)
trace('rec-card', original_image, {
'card_count': card_count,
'type': filtered_results[0].type,
'score': filtered_results[0].score,
'borders': (
filtered_results[0].left_score,
filtered_results[0].right_score,
filtered_results[0].top_score,
filtered_results[0].bottom_score
)
})
return filtered_results[0]

View File

@ -1,26 +1,19 @@
import time
import logging
from typing_extensions import deprecated, assert_never
from itertools import cycle
from typing import Generic, Iterable, Literal, NamedTuple, Callable, Generator, TypeVar, ParamSpec, cast
import cv2
import numpy as np
from cv2.typing import MatLike
from typing_extensions import assert_never
from typing import Literal, NamedTuple
from .. import R
from ..actions import loading
from ..game_ui import WhiteFilter
from ..actions.scenes import at_home
from ..util.trace import trace
from ..game_ui import WhiteFilter, dialog
from .cards import do_cards, CardDetectResult
from ..actions.commu import handle_unread_commu
from ..common import ProduceAction, RecommendCardDetectionMode, conf
from kotonebot.errors import UnrecoverableError
from kotonebot.backend.context.context import use_screenshot
from kotonebot.util import Countdown, Interval, cropped
from kotonebot.backend.dispatch import DispatcherContext
from ..common import ProduceAction, RecommendCardDetectionMode, conf
from ..produce.common import until_acquisition_clear, commut_event, fast_acquisitions
from kotonebot.util import Countdown, Interval, crop, cropped, Stopwatch
from kotonebot.backend.dispatch import DispatcherContext, SimpleDispatcher
from kotonebot import ocr, device, contains, image, regex, action, sleep, color, Rect, wait
from kotonebot import ocr, device, contains, image, regex, action, sleep, Rect, wait
from ..produce.non_lesson_actions import (
enter_allowance, allowance_available,
study_available, enter_study,
@ -28,34 +21,8 @@ from ..produce.non_lesson_actions import (
outing_available, enter_outing
)
class SkillCard(NamedTuple):
available: bool
rect: Rect
logger = logging.getLogger(__name__)
ActionType = None | Literal['lesson', 'rest']
CARD_POSITIONS_1 = [
# 格式:(x, y, w, h, return_value)
(264, 883, 192, 252, 0)
]
CARD_POSITIONS_2 = [
(156, 883, 192, 252, 0),
(372, 883, 192, 252, 1),
# delta_x = 216, delta_x-width = 24
]
CARD_POSITIONS_3 = [
(47, 883, 192, 252, 0), # 左卡片 (x, y, w, h)
(264, 883, 192, 252, 1), # 中卡片
(481, 883, 192, 252, 2) # 右卡片
# delta_x = 217, delta_x-width = 25
]
CARD_POSITIONS_4 = [
(17, 883, 192, 252, 0),
(182, 883, 192, 252, 1),
(346, 883, 192, 252, 2),
(511, 883, 192, 252, 3),
# delta_x = 165, delta_x-width = -27
]
@action('执行 SP 课程')
def handle_sp_lesson():
@ -150,232 +117,6 @@ def handle_recommended_action(final_week: bool = False) -> ProduceAction | None:
device.double_click(image.expect(template))
return recommended
class CardDetectResult(NamedTuple):
type: int
"""
点击的卡片类型
0=第一张卡片1=第二张卡片2=第三张卡片3=第四张卡片10=SKIP
"""
score: float
"""总分数"""
left_score: float
"""左边分数"""
right_score: float
"""右边分数"""
top_score: float
"""上边分数"""
bottom_score: float
"""下边分数"""
rect: Rect
def detect_recommended_card(
card_count: int,
threshold_predicate: Callable[[CardDetectResult], bool],
*,
img: MatLike | None = None,
):
"""
识别推荐卡片
前置条件练习或考试中\n
结束状态-
:param card_count: 卡片数量(2-4)
:param threshold_predicate: 阈值判断函数
:return: 执行结果若返回 None表示未识别到推荐卡片
"""
YELLOW_LOWER = np.array([20, 100, 100])
YELLOW_UPPER = np.array([30, 255, 255])
SKIP_POSITION = (621, 739, 85, 85, 10)
GLOW_EXTENSION = 15
if card_count == 4:
logger.info("4 cards detected. Currently not supported. Use 1st card.")
return CardDetectResult(
type=0,
score=1,
left_score=1,
right_score=1,
top_score=1,
bottom_score=1,
rect=(17, 883, 192, 252)
)
if card_count == 5:
logger.info("5 cards detected. Currently not supported. Use 1st card.")
return CardDetectResult(
type=0,
score=1,
left_score=1,
right_score=1,
top_score=1,
bottom_score=1,
rect=R.InPurodyuusu.BoxLessonCards5_1
)
if card_count == 1:
cards = CARD_POSITIONS_1
elif card_count == 2:
cards = CARD_POSITIONS_2
elif card_count == 3:
cards = CARD_POSITIONS_3
elif card_count == 4:
cards = CARD_POSITIONS_4
else:
raise ValueError(f"Unsupported card count: {card_count}")
cards.append(SKIP_POSITION)
image = use_screenshot(img)
original_image = image.copy()
results: list[CardDetectResult] = []
for x, y, w, h, return_value in cards:
outer = (max(0, x - GLOW_EXTENSION), max(0, y - GLOW_EXTENSION))
# 裁剪出检测区域
glow_area = image[outer[1]:y + h + GLOW_EXTENSION, outer[0]:x + w + GLOW_EXTENSION]
area_h = glow_area.shape[0]
area_w = glow_area.shape[1]
glow_area[GLOW_EXTENSION:area_h-GLOW_EXTENSION, GLOW_EXTENSION:area_w-GLOW_EXTENSION] = 0
# 过滤出目标黄色
glow_area = cv2.cvtColor(glow_area, cv2.COLOR_BGR2HSV)
yellow_mask = cv2.inRange(glow_area, YELLOW_LOWER, YELLOW_UPPER)
# 分割出每一边
left_border = yellow_mask[:, 0:GLOW_EXTENSION]
right_border = yellow_mask[:, area_w-GLOW_EXTENSION:area_w]
top_border = yellow_mask[0:GLOW_EXTENSION, :]
bottom_border = yellow_mask[area_h-GLOW_EXTENSION:area_h, :]
y_border_pixels = area_h * GLOW_EXTENSION
x_border_pixels = area_w * GLOW_EXTENSION
# 计算每一边的分数
left_score = np.count_nonzero(left_border) / y_border_pixels
right_score = np.count_nonzero(right_border) / y_border_pixels
top_score = np.count_nonzero(top_border) / x_border_pixels
bottom_score = np.count_nonzero(bottom_border) / x_border_pixels
result = (left_score + right_score + top_score + bottom_score) / 4
results.append(CardDetectResult(
return_value,
result,
left_score,
right_score,
top_score,
bottom_score,
(x, y, w, h)
))
filtered_results = list(filter(threshold_predicate, results))
if not filtered_results:
max_result = max(results, key=lambda x: x.score)
logger.info("Max card detect result (discarded): value=%d score=%.4f borders=(%.4f, %.4f, %.4f, %.4f)",
max_result.type,
max_result.score,
max_result.left_score,
max_result.right_score,
max_result.top_score,
max_result.bottom_score
)
return None
filtered_results.sort(key=lambda x: x.score, reverse=True)
logger.info("Max card detect result: value=%d score=%.4f borders=(%.4f, %.4f, %.4f, %.4f)",
filtered_results[0].type,
filtered_results[0].score,
filtered_results[0].left_score,
filtered_results[0].right_score,
filtered_results[0].top_score,
filtered_results[0].bottom_score
)
# 跟踪检测结果
if conf().trace.recommend_card_detection:
x, y, w, h = filtered_results[0].rect
cv2.rectangle(original_image, (x, y), (x+w, y+h), (0, 0, 255), 3)
trace('rec-card', original_image, {
'card_count': card_count,
'type': filtered_results[0].type,
'score': filtered_results[0].score,
'borders': (
filtered_results[0].left_score,
filtered_results[0].right_score,
filtered_results[0].top_score,
filtered_results[0].bottom_score
)
})
return filtered_results[0]
def handle_recommended_card(
card_count: int, timeout: float = 7,
threshold_predicate: Callable[[CardDetectResult], bool] = lambda _: True,
*,
img: MatLike | None = None,
):
result = detect_recommended_card(card_count, threshold_predicate, img=img)
if result is not None:
device.double_click(result)
return result
return None
@action('获取当前卡片数量', screenshot_mode='manual-inherit')
def skill_card_count(img: MatLike | None = None):
"""获取当前持有的技能卡数量"""
img = use_screenshot(img)
img = crop(img, y1=0.83, y2=0.90)
count = image.raw().count(img, R.InPurodyuusu.A)
count += image.raw().count(img, R.InPurodyuusu.M)
count += image.raw().count(img, R.InPurodyuusu.T)
logger.info("Current skill card count: %d", count)
return count
Yield = TypeVar('Yield')
Send = TypeVar('Send')
Return = TypeVar('Return')
P = ParamSpec('P')
class GeneratorWrapper(Iterable[Yield], Generic[P, Yield, Send, Return]):
def __init__(
self,
generator_func: Callable[P, Generator[Yield, Send, Return]],
*args: P.args,
**kwargs: P.kwargs
):
self.generator_func = generator_func
self.generator = generator_func(*args, **kwargs)
self.args = args
self.kwargs = kwargs
def __iter__(self):
return self
def __call__(self):
return next(self.generator)
def reset(self):
self.generator = self.generator_func(*self.args, **self.kwargs)
def loop(self) -> Return:
while True:
try:
next(self.generator)
except StopIteration as e:
return cast(Return, e.value)
@action('获取当前卡牌信息', screenshot_mode='manual-inherit')
def obtain_cards(img: MatLike | None = None):
img = use_screenshot(img)
cards_rects = image.find_all_multi([
R.InPurodyuusu.A,
R.InPurodyuusu.M,
R.InPurodyuusu.T
])
logger.info("Current cards: %s", len(cards_rects))
cards = []
for result in cards_rects:
available = color.find('#7a7d7d', rect=result.rect) is None
cards.append(SkillCard(available=available, rect=result.rect))
return cards
@action('等待进入行动场景')
def until_action_scene(week_first: bool = False):
@ -416,139 +157,6 @@ def until_exam_scene():
while ocr.find(regex("合格条件|三位以上")) is None and not is_exam_scene():
until_acquisition_clear()
@action("技能卡移动")
def handle_skill_card_move():
"""
前置条件技能卡移动对话框\n
结束状态对话框结束瞬间
"""
cards = image.find_all_multi([
R.InPurodyuusu.A,
R.InPurodyuusu.M
])
if not cards:
logger.info("No skill cards found")
return False
it = Interval()
cd = Countdown(sec=3)
while True:
device.screenshot()
# 判断对话框是否关闭
# 已关闭,开始计时
if not image.find(R.InPurodyuusu.IconTitleSkillCardMove):
cd.start()
if cd.expired():
logger.info("Skill card move dialog closed.")
break
# 没有,要继续选择并确定
else:
cd.reset()
card = cards.pop()
device.double_click(card)
sleep(1)
dialog.yes()
it.wait()
logger.debug("Handle skill card move finished.")
@action('打牌', screenshot_mode='manual')
def do_cards(
threshold_predicate: Callable[[CardDetectResult], bool],
end_predicate: Callable[[], bool]
):
"""
循环打出推荐卡直到考试/练习结束
前置条件考试/练习页面\n
结束状态考试/练习结束的一瞬间
:param threshold_predicate: 推荐卡检测阈值判断函数
:param end_predicate: 结束条件判断函数
"""
it = Interval(seconds=1/30)
timeout_cd = Countdown(sec=120).start() # 推荐卡检测超时计时器
break_cd = Countdown(sec=3).start() # 满足结束条件计时器
no_card_cd = Countdown(sec=4) # 无手牌计时器
detect_card_count_cd = Countdown(sec=4).start() # 刷新检测手牌数量间隔
tries = 1
card_count = -1
while True:
device.click(0, 0)
img = device.screenshot()
it.wait()
# 技能卡自选移动对话框
if image.find(R.InPurodyuusu.IconTitleSkillCardMove):
if handle_skill_card_move():
sleep(4) # 等待卡片刷新
continue
# 技能卡效果无法发动对话框
if image.find(R.Common.ButtonIconCheckMark):
logger.info("Confirmation dialog detected")
device.click()
sleep(4) # 等待卡片刷新
continue
# 更新卡片数量
if card_count == -1 or detect_card_count_cd.expired():
detect_card_count_cd.reset()
card_count = skill_card_count(img)
logger.debug("Current card count: %d", card_count)
# 处理手牌
if card_count == 0:
# 处理本回合已无剩余手牌的情况
# TODO: 使用模板匹配而不是 OCR提升速度
no_card_cd.start()
no_remaining_card = ocr.find(contains("0枚"), rect=R.InPurodyuusu.BoxNoSkillCard)
if no_remaining_card and no_card_cd.expired():
logger.debug('No remaining card detected. Skip this turn.')
# TODO: HARD CODEDED
SKIP_POSITION = (621, 739, 85, 85)
device.click(SKIP_POSITION)
no_card_cd.reset()
continue
else:
if handle_recommended_card(
card_count=card_count,
threshold_predicate=threshold_predicate,
img=img
):
logger.info("Handle recommended card success with %d tries", tries)
sleep(4.5)
tries = 0
timeout_cd.reset()
continue
else:
tries += 1
# 检测超时(防止一直卡在检测)
if timeout_cd.expired():
logger.info("Recommend card detection timed out. Click first card.")
if card_count == 1:
card_rect = CARD_POSITIONS_1[0]
elif card_count == 2:
card_rect = CARD_POSITIONS_2[0]
elif card_count == 3:
card_rect = CARD_POSITIONS_3[0]
elif card_count == 4:
card_rect = CARD_POSITIONS_4[0]
else:
raise ValueError("Invalid card count: %d" % card_count)
device.double_click(card_rect[:4])
timeout_cd.reset()
# 结束条件
if card_count == 0 and end_predicate():
if not break_cd.started:
break_cd.start()
if break_cd.expired():
break
else:
break_cd.reset()
logger.info("CLEAR/PERFECT not found. Practice finished.")
@action('执行练习', screenshot_mode='manual')
def practice():
"""
@ -559,7 +167,7 @@ def practice():
"""
logger.info("Practice started")
def threshold_predicate(result: CardDetectResult):
def threshold_predicate(card_count: int, result: CardDetectResult):
border_scores = (result.left_score, result.right_score, result.top_score, result.bottom_score)
is_strict_mode = conf().produce.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
if is_strict_mode:
@ -592,36 +200,40 @@ def exam(type: Literal['mid', 'final']):
"""
logger.info("Exam started")
def threshold_predicate(result: CardDetectResult):
def threshold_predicate(card_count: int, result: CardDetectResult):
is_strict_mode = conf().produce.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
total = lambda t: result.score >= t
def borders(t):
# 卡片数量小于三时无遮挡,以及最后一张卡片也总是无遮挡
if card_count <= 3 or (result.type == card_count - 1):
return (
result.left_score >= t
and result.right_score >= t
and result.top_score >= t
and result.bottom_score >= t
)
# 其他情况下,卡片的右侧会被挡住,并不会发光
else:
return (
result.left_score >= t
and result.top_score >= t
and result.bottom_score >= t
)
if is_strict_mode:
if type == 'final':
return (
result.score >= 0.4
and result.left_score >= 0.2
and result.right_score >= 0.2
and result.top_score >= 0.2
and result.bottom_score >= 0.2
)
return total(0.4) and borders(0.2)
else:
return (
result.score >= 0.10
and result.left_score >= 0.01
and result.right_score >= 0.01
and result.top_score >= 0.01
and result.bottom_score >= 0.01
)
return total(0.10) and borders(0.01)
else:
if type == 'final':
return (
result.score >= 0.4
and result.left_score >= 0.2
and result.right_score >= 0.2
and result.top_score >= 0.2
and result.bottom_score >= 0.2
)
if result.type == 10: # SKIP
return total(0.4) and borders(0.02)
else:
return total(0.2) and borders(0.02)
else:
return result.score >= 0.10
return total(0.10) and borders(0.01)
# 关于上面阈值的解释:
# 所有阈值均指卡片周围的“黄色度”,
# score 指卡片四边的平均黄色度阈值,