feat(*): 优化若干流程

1. 添加 wait() 函数,允许在截图前等待指定时间
2. 以调试模式启动时,新增了删除原有调试 dump 文件夹时对文件被占用的处理
3. 修复了培育任务无法从已有培育流程开始的问题
4. 修复了购买推荐商品会卡住的问题
5. 优化未读交流判断逻辑提高准确率
6. 新增练习/考试中对本回合已无可用手牌情况的判断
7. 优化若干流程在 u2 截图模式下的逻辑
8. 消息推送新增失败时自动重试
This commit is contained in:
XcantloadX 2025-02-16 17:11:35 +08:00
parent be7acd3102
commit 726515774e
17 changed files with 152 additions and 37 deletions

View File

@ -13,7 +13,8 @@ from .backend.context import (
sleep,
task,
action,
use_screenshot
use_screenshot,
wait
)
from .backend.util import (
Rect,

View File

@ -141,7 +141,7 @@ def sleep(seconds: float, /):
"""
可中断的 sleep 函数
建议使用 `context.sleep()` 代替 `time.sleep()`
建议使用本函数代替 `time.sleep()`
这样能以最快速度响应用户请求中断
"""
global vars
@ -655,6 +655,7 @@ class ContextDevice(Device):
"""
截图返回截图数据同时更新当前上下文的截图数据
"""
global next_wait, last_screenshot_time, next_wait_time
current = ContextStackVars.ensure_current()
if force:
current._inherit_screenshot = None
@ -662,6 +663,13 @@ class ContextDevice(Device):
img = current._inherit_screenshot
current._inherit_screenshot = None
else:
if next_wait == 'screenshot':
delta = time.time() - last_screenshot_time
if delta < next_wait_time:
sleep(next_wait_time - delta)
last_screenshot_time = time.time()
next_wait_time = 0
next_wait = None
img = self._device.screenshot()
current._screenshot = img
return img
@ -691,7 +699,7 @@ class Context(Generic[T]):
ip = self.config.current.backend.adb_ip
port = self.config.current.backend.adb_port
# TODO: 处理链接失败情况
self.__device = ContextDevice(create_device(f'{ip}:{port}', 'adb_raw'))
self.__device = ContextDevice(create_device(f'{ip}:{port}', 'adb'))
def inject(
self,
@ -763,6 +771,15 @@ def use_screenshot(*args: MatLike | None) -> MatLike:
return img
return device.screenshot()
WaitBeforeType = Literal['screenshot']
def wait(at_least: float = 0.3, *, before: WaitBeforeType) -> None:
global next_wait, next_wait_time
if before == 'screenshot':
if time.time() - last_screenshot_time < at_least:
next_wait = 'screenshot'
next_wait_time = at_least
# 这里 Context 类还没有初始化,但是 tasks 中的脚本可能已经引用了这里的变量
# 为了能够动态更新这里变量的值,这里使用 Forwarded 类再封装一层,
# 将调用转发到实际的稍后初始化的 Context 类上
@ -781,7 +798,10 @@ debug: ContextDebug = cast(ContextDebug, Forwarded(name="debug"))
"""调试工具。"""
config: ContextConfig = cast(ContextConfig, Forwarded(name="config"))
"""配置数据。"""
last_screenshot_time: float = -1
"""上一次截图的时间。"""
next_wait: WaitBeforeType | None = None
next_wait_time: float = 0
def init_context(
*,

View File

@ -7,8 +7,11 @@ from pathlib import Path
from threading import Thread
from . import debug
from kotonebot import logging
from kotonebot.backend.context import init_context
logger = logging.getLogger(__name__)
def _task_thread(task_module: str):
"""任务线程。"""
runpy.run_module(task_module, run_name="__main__")
@ -57,7 +60,18 @@ if __name__ == "__main__":
os.makedirs(save_path)
if args.clear:
if debug.auto_save_to_folder:
shutil.rmtree(debug.auto_save_to_folder)
try:
logger.info(f"Removing {debug.auto_save_to_folder}")
shutil.rmtree(debug.auto_save_to_folder)
except PermissionError:
logger.warning(f"Failed to remove {debug.auto_save_to_folder}. Trying to remove all contents instead.")
for root, dirs, files in os.walk(debug.auto_save_to_folder):
for file in files:
try:
os.remove(os.path.join(root, file))
except PermissionError:
raise
# 初始化上下文
module_name, class_name = args.config_type.rsplit('.', 1)

View File

@ -240,13 +240,13 @@ class UntilImage:
self.sd.result = self.result
class SimpleDispatcher:
def __init__(self, name: str, *, interval: float = 0.2):
def __init__(self, name: str, *, min_interval: float = 0.3):
self.name = name
self.logger = logging.getLogger(f'SimpleDispatcher of {name}')
self.blocks: list[Callable] = []
self.finished: bool = False
self.result: Any | None = None
self.interval = interval
self.min_interval = min_interval
self.timeout_value: float | None = None
self.timeout_critical: bool = False
self.__last_run_time: float = 0
@ -306,8 +306,8 @@ class SimpleDispatcher:
while True:
logger.debug(f'Running dispatcher "{self.name}"')
time_delta = time.time() - self.__last_run_time
if time_delta < self.interval:
sleep(self.interval - time_delta)
if time_delta < self.min_interval:
sleep(self.min_interval - time_delta)
for block in self.blocks:
block()
if self.finished:

View File

@ -16,7 +16,7 @@ from kotonebot import (
from ..game_ui import CommuEventButtonUI
from .pdorinku import acquire_pdorinku
from kotonebot.backend.dispatch import SimpleDispatcher
from kotonebot.tasks.actions.commu import check_and_skip_commu
from kotonebot.tasks.actions.commu import handle_unread_commu
logger = getLogger(__name__)
@ -109,7 +109,11 @@ def acquisitions() -> AcquisitionType | None:
if image.find(R.InPurodyuusu.TextPDrinkMax):
logger.info("PDrink max found")
while True:
if image.find(R.InPurodyuusu.ButtonLeave, colored=True):
# TODO: 这里会因为截图速度过快,截图截到中间状态的弹窗。
# 然后又因为从截图、识别、发出点击到实际点击中间又延迟,
# 过了这段时间后,原来中间状态按钮所在的位置已经变成了其他
# 的东西,导致误点击
if image.find(R.InPurodyuusu.ButtonLeave, colored=True): # mark
device.click()
elif image.find(R.Common.ButtonConfirm):
device.click()
@ -134,7 +138,7 @@ def acquisitions() -> AcquisitionType | None:
device.click_center()
sleep(5)
# TODO: 可能不存在 達成 NEXT
logger.debug("達成 NEXT: clicked")
logger.debug("達成 NEXT: clicked") # TODO: 需要截图
device.click_center()
return "Clear"
# P物品领取
@ -153,7 +157,7 @@ def acquisitions() -> AcquisitionType | None:
return "NetworkError"
# 跳过未读交流
logger.debug("Check skip commu...")
if check_and_skip_commu(img):
if handle_unread_commu(img):
return "SkipCommu"
# === 需要 OCR 的放在最后执行 ===

View File

@ -3,6 +3,8 @@ import logging
from cv2.typing import MatLike
from kotonebot.backend.util import Countdown, Interval
from .. import R
from kotonebot import device, image, color, user, rect_expand, until, action, sleep, use_screenshot
@ -17,8 +19,8 @@ def is_at_commu():
def skip_commu():
device.click(image.expect_wait(R.Common.ButtonCommuSkip))
@action('检查并跳过交流', screenshot_mode='manual')
def check_and_skip_commu(img: MatLike | None = None) -> bool:
@action('检查未读交流', screenshot_mode='manual')
def handle_unread_commu(img: MatLike | None = None) -> bool:
"""
检查当前是否处在未读交流并自动跳过
@ -36,14 +38,39 @@ def check_and_skip_commu(img: MatLike | None = None) -> bool:
ret = True
logger.debug('Fast forward button found. Check commu')
button_bg_rect = rect_expand(skip_btn.rect, 10, 10, 50, 10)
colors = color.raw().dominant_color(img, 2, rect=button_bg_rect)
RANGE = ((20, 65, 95), (180, 100, 100))
if not any(color.raw().in_range(c, RANGE) for c in colors):
def is_fastforwarding():
nonlocal img
assert img is not None
colors = color.raw().dominant_color(img, 2, rect=button_bg_rect)
RANGE = ((20, 65, 95), (180, 100, 100))
return any(color.raw().in_range(c, RANGE) for c in colors)
# 防止截图速度过快时,截图到了未加载完全的画面
cd = Interval()
hit = 0
HIT_THRESHOLD = 2
while True:
if not is_fastforwarding():
logger.debug("Unread commu hit %d/%d", hit, HIT_THRESHOLD)
hit += 1
else:
hit = 0
break
if hit >= HIT_THRESHOLD:
break
cd.wait()
img = device.screenshot()
should_skip = hit >= HIT_THRESHOLD
if not should_skip:
logger.info('Fast forwarding. No action needed.')
return False
if should_skip:
user.info('发现未读交流', [img])
logger.debug('Not fast forwarding. Click fast forward button')
device.click(skip_btn)
sleep(0.7)
if image.find(R.Common.ButtonConfirm):
if image.wait_for(R.Common.ButtonConfirm, timeout=5):
logger.debug('Click confirm button')
device.click()
else:

View File

@ -9,17 +9,17 @@ import cv2
import numpy as np
from cv2.typing import MatLike
from kotonebot.backend.context.context import use_screenshot
from .. import R
from . import loading
from ..common import conf
from .scenes import at_home
from .common import until_acquisition_clear, acquisitions, commut_event
from kotonebot.errors import UnrecoverableError
from kotonebot.backend.context.context import use_screenshot
from .common import until_acquisition_clear, acquisitions, commut_event
from kotonebot.backend.util import AdaptiveWait, Countdown, crop, cropped
from kotonebot.backend.dispatch import DispatcherContext, SimpleDispatcher
from kotonebot import ocr, device, contains, image, regex, action, sleep, color, Rect
from kotonebot import ocr, device, contains, image, regex, action, sleep, color, Rect, wait
from .non_lesson_actions import (
enter_allowance, allowance_available, study_available, enter_study,
is_rest_available, rest
@ -516,6 +516,7 @@ def exam(type: Literal['mid', 'final']):
wait = cycle([0.1, 0.3, 0.5])
tries = 1
no_card_cd = Countdown(sec=4)
while True:
start_time = time.time()
img = device.screenshot()
@ -526,6 +527,17 @@ def exam(type: Literal['mid', 'final']):
continue
card_count = skill_card_count(img)
if card_count == 0:
# 处理本回合已无剩余手牌的情况
# TODO: 使用模板匹配而不是 OCR提升速度
no_remaining_card = ocr.find(contains("0枚"), rect=R.InPurodyuusu.BoxNoSkillCard)
if no_remaining_card:
# TODO: HARD CODEDED
SKIP_POSITION = (621, 739, 85, 85)
device.click(SKIP_POSITION)
no_card_cd.reset()
continue
if card_count > 0:
inner_tries = 0
while True:
@ -579,9 +591,9 @@ def produce_end():
# 等待选择封面画面 [screenshots/produce_end/select_cover.jpg]
# 次へ
logger.info("Waiting for select cover screen...")
wait = AdaptiveWait(timeout=60 * 5, max_interval=20)
aw = AdaptiveWait(timeout=60 * 5, max_interval=20)
while not image.find(R.InPurodyuusu.ButtonNextNoIcon):
wait()
aw()
device.click(0, 0)
# 选择封面
logger.info("Use default cover.")
@ -657,12 +669,12 @@ def produce_end():
if image.find(R.InPurodyuusu.ButtonNextNoIcon):
logger.debug("Click next")
device.click()
sleep(0.2)
wait(0.5, before='screenshot')
# [screenshots/produce_end/end_complete.png]
elif image.find(R.InPurodyuusu.ButtonComplete):
logger.debug("Click complete")
device.click(image.expect_wait(R.InPurodyuusu.ButtonComplete))
sleep(0.2)
wait(0.5, before='screenshot')
break
# 点击结束后可能还会弹出来:
@ -957,7 +969,7 @@ if __name__ == '__main__':
# produce_end()
# hajime_pro(start_from=15)
# hajime_pro(start_from=16)
# exam('mid')
stage = (detect_regular_produce_scene())
hajime_regular_from_stage(stage)

View File

@ -5,6 +5,9 @@
"""
from logging import getLogger
from kotonebot.backend.dispatch import SimpleDispatcher
from kotonebot.backend.util import Interval
from .. import R
from ..game_ui import CommuEventButtonUI, EventButton
from .common import acquisitions, AcquisitionType
@ -83,6 +86,7 @@ def enter_allowance():
logger.debug("Waiting for 活動支給 screen.")
acquisitions()
# 领取奖励
it = Interval()
while True:
# TODO: 检测是否在行动页面应当单独一个函数
if image.find_multi([
@ -93,9 +97,11 @@ def enter_allowance():
if image.find(R.InPurodyuusu.LootboxSliverLock):
logger.info("Click on lootbox.")
device.click()
sleep(0.5) # 防止点击了第一个箱子后立马点击了第二个
continue
if acquisitions() is not None:
continue
it.wait()
logger.info("活動支給 completed.")
@action('判断是否可以休息')
@ -110,10 +116,12 @@ def is_rest_available():
def rest():
"""执行休息"""
logger.info("Rest for this week.")
# 点击休息
device.click(image.expect_wait(R.InPurodyuusu.Rest))
# 确定
device.click(image.expect_wait(R.InPurodyuusu.RestConfirmBtn))
(SimpleDispatcher('in_produce.rest')
# 点击休息
.click(R.InPurodyuusu.Rest)
# 确定
.click(R.InPurodyuusu.RestConfirmBtn, finish=True)
).run()
if __name__ == '__main__':
from kotonebot.backend.context import manual_context, init_context

View File

@ -36,6 +36,7 @@ def check_and_goto_mission() -> bool:
def claim_mission_reward(name: str):
"""领取任务奖励"""
# [screenshots/mission/daily.png]
image.expect_wait(R.Common.ButtonIconArrowShort)
if image.find(R.Common.ButtonIconArrowShort, colored=True):
logger.info(f'Claiming {name} mission reward.')
device.click()

View File

@ -2,6 +2,7 @@ import logging
from itertools import cycle
from typing import Optional, Literal
from kotonebot.backend.context.context import wait
from kotonebot.ui import user
from kotonebot.backend.util import Countdown
from kotonebot.backend.dispatch import SimpleDispatcher
@ -9,7 +10,7 @@ from kotonebot.backend.dispatch import SimpleDispatcher
from . import R
from .common import conf, PIdol
from .actions.scenes import at_home, goto_home
from .actions.in_purodyuusu import hajime_pro, hajime_regular
from .actions.in_purodyuusu import hajime_pro, hajime_regular, resume_regular_produce
from kotonebot import device, image, ocr, task, action, sleep, equals, contains
logger = logging.getLogger(__name__)
@ -79,6 +80,7 @@ def select_idol(target_titles: list[str] | PIdol):
# 如果不是,就挨个选中,判断名称
for r in results:
device.click(r)
sleep(0.3)
device.screenshot()
if all(ocr.find_all(_target_titles, rect=R.Produce.KbIdolOverviewName)):
found = True
@ -113,6 +115,8 @@ def resume_produce():
# [res/sprites/jp/produce/produce_resume.png]
logger.info('Click resume button.')
device.click(image.expect_wait(R.Produce.ButtonResume))
# 继续流程
resume_regular_produce()
@action('执行培育', screenshot_mode='manual-inherit')
def do_produce(idol: PIdol, mode: Literal['regular', 'pro']) -> bool:
@ -154,11 +158,13 @@ def do_produce(idol: PIdol, mode: Literal['regular', 'pro']) -> bool:
# 2. 选择支援卡 自动编成 [screenshots/produce/select_support_card.png]
ocr.expect_wait(contains('サポート'), rect=R.Produce.BoxStepIndicator)
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
wait(0.5, before='screenshot')
device.click(image.expect_wait(R.Common.ButtonConfirm, colored=True))
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
device.click(image.expect_wait(R.Common.ButtonNextNoIcon, colored=True))
# 3. 选择回忆 自动编成 [screenshots/produce/select_memory.png]
ocr.expect_wait(contains('メモリー'), rect=R.Produce.BoxStepIndicator)
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
wait(0.5, before='screenshot')
device.screenshot()
(SimpleDispatcher('do_produce.step_3')
.click(R.Common.ButtonNextNoIcon)
@ -236,10 +242,13 @@ if __name__ == '__main__':
file_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s'))
logging.getLogger().addHandler(file_handler)
from kotonebot.backend.context import init_context
import time
from kotonebot.backend.context import init_context, manual_context
from kotonebot.tasks.common import BaseConfig
init_context(config_type=BaseConfig)
conf().produce.enabled = True
conf().produce.mode = 'regular'
conf().produce.idols = [PIdol.花海佑芽_学園生活]
produce_task()
# a()
# select_idol(PIdol.藤田ことね_学園生活)

View File

@ -71,6 +71,7 @@ def dispatch_recommended_items():
logger.info(f'Start purchasing recommended items.')
while True:
device.screenshot()
if image.find(R.Daily.TextShopRecommended):
logger.info(f'Clicking on recommended item.') # TODO: 计数
device.click()

View File

@ -6,7 +6,7 @@ from . import R
from .common import Priority
from .actions.loading import loading
from .actions.scenes import at_home, goto_home
from .actions.commu import is_at_commu, check_and_skip_commu
from .actions.commu import is_at_commu, handle_unread_commu
logger = logging.getLogger(__name__)
@task('启动游戏', priority=Priority.START_GAME)
@ -42,7 +42,7 @@ def start_game():
elif image.find(R.Common.ButtonIconClose):
device.click()
# [screenshots/startup/birthday.png]
elif check_and_skip_commu():
elif handle_unread_commu():
pass
else:
device.click_center()

View File

@ -10,6 +10,21 @@ from .. import logging
logger = logging.getLogger(__name__)
def retry(func):
"""
装饰器当函数发生 ConnectionResetError 时自动重试三次
"""
def wrapper(*args, **kwargs):
for i in range(3):
try:
return func(*args, **kwargs)
except ConnectionResetError:
if i == 2: # 最后一次重试失败
raise
logger.warning(f'ConnectionResetError raised when calling {func}, retrying {i+1}/{3}')
continue
return wrapper
def ask(
question: str,
options: list[str],
@ -40,6 +55,7 @@ def _save_local(
logger.verbose('saving image to local: %s', f'{file_name}_{i}.png')
cv2.imwrite(f'{file_name}_{i}.png', image)
@retry
def push(
title: str,
message: str,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1 @@
{"definitions":{"c74f2151-74b0-4b47-bf80-356c48f431e0":{"name":"InPurodyuusu.BoxNoSkillCard","displayName":"手札のスキルカ学ドが0枚です","type":"hint-box","annotationId":"c74f2151-74b0-4b47-bf80-356c48f431e0","useHintRect":false}},"annotations":[{"id":"c74f2151-74b0-4b47-bf80-356c48f431e0","type":"rect","data":{"x1":180,"y1":977,"x2":529,"y2":1026}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@ -0,0 +1 @@
{"definitions":{"582d36c0-0916-4706-9833-4fbc026701f5":{"name":"InPurodyuusu.BoxPDrinkMaxConfirmTitle","displayName":"P饮料溢出 不领取弹窗标题","type":"hint-box","annotationId":"582d36c0-0916-4706-9833-4fbc026701f5","useHintRect":false}},"annotations":[{"id":"582d36c0-0916-4706-9833-4fbc026701f5","type":"rect","data":{"x1":46,"y1":829,"x2":270,"y2":876}}]}