diff --git a/kotonebot/__init__.py b/kotonebot/__init__.py index 2d48904..107723b 100644 --- a/kotonebot/__init__.py +++ b/kotonebot/__init__.py @@ -13,7 +13,8 @@ from .backend.context import ( sleep, task, action, - use_screenshot + use_screenshot, + wait ) from .backend.util import ( Rect, diff --git a/kotonebot/backend/context/context.py b/kotonebot/backend/context/context.py index a91ca10..80a76b6 100644 --- a/kotonebot/backend/context/context.py +++ b/kotonebot/backend/context/context.py @@ -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( *, diff --git a/kotonebot/backend/debug/entry.py b/kotonebot/backend/debug/entry.py index eff5a8a..5f79ce0 100644 --- a/kotonebot/backend/debug/entry.py +++ b/kotonebot/backend/debug/entry.py @@ -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) diff --git a/kotonebot/backend/dispatch.py b/kotonebot/backend/dispatch.py index f2e9eda..65c7178 100644 --- a/kotonebot/backend/dispatch.py +++ b/kotonebot/backend/dispatch.py @@ -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: diff --git a/kotonebot/tasks/actions/common.py b/kotonebot/tasks/actions/common.py index 5e45041..1fd9c35 100644 --- a/kotonebot/tasks/actions/common.py +++ b/kotonebot/tasks/actions/common.py @@ -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,11 +109,15 @@ 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() - break + break device.screenshot() return "PDrinkMax" # 技能卡领取 @@ -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 的放在最后执行 === diff --git a/kotonebot/tasks/actions/commu.py b/kotonebot/tasks/actions/commu.py index ca44ac3..c317596 100644 --- a/kotonebot/tasks/actions/commu.py +++ b/kotonebot/tasks/actions/commu.py @@ -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: diff --git a/kotonebot/tasks/actions/in_purodyuusu.py b/kotonebot/tasks/actions/in_purodyuusu.py index b9a221b..285a2dd 100644 --- a/kotonebot/tasks/actions/in_purodyuusu.py +++ b/kotonebot/tasks/actions/in_purodyuusu.py @@ -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) diff --git a/kotonebot/tasks/actions/non_lesson_actions.py b/kotonebot/tasks/actions/non_lesson_actions.py index b168776..5a9a2d7 100644 --- a/kotonebot/tasks/actions/non_lesson_actions.py +++ b/kotonebot/tasks/actions/non_lesson_actions.py @@ -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 diff --git a/kotonebot/tasks/mission_reward.py b/kotonebot/tasks/mission_reward.py index 75dd8ab..c2b5938 100644 --- a/kotonebot/tasks/mission_reward.py +++ b/kotonebot/tasks/mission_reward.py @@ -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() diff --git a/kotonebot/tasks/produce.py b/kotonebot/tasks/produce.py index 32749f5..c52d95b 100644 --- a/kotonebot/tasks/produce.py +++ b/kotonebot/tasks/produce.py @@ -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.藤田ことね_学園生活) \ No newline at end of file diff --git a/kotonebot/tasks/purchase.py b/kotonebot/tasks/purchase.py index 891dfa3..28f4edd 100644 --- a/kotonebot/tasks/purchase.py +++ b/kotonebot/tasks/purchase.py @@ -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() diff --git a/kotonebot/tasks/start_game.py b/kotonebot/tasks/start_game.py index 3921050..f21486a 100644 --- a/kotonebot/tasks/start_game.py +++ b/kotonebot/tasks/start_game.py @@ -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() diff --git a/kotonebot/ui/user.py b/kotonebot/ui/user.py index 95961ab..c697098 100644 --- a/kotonebot/ui/user.py +++ b/kotonebot/ui/user.py @@ -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, diff --git a/res/sprites/jp/in_purodyuusu/screenshot_lesson_no_card.png b/res/sprites/jp/in_purodyuusu/screenshot_lesson_no_card.png new file mode 100644 index 0000000..905f28b Binary files /dev/null and b/res/sprites/jp/in_purodyuusu/screenshot_lesson_no_card.png differ diff --git a/res/sprites/jp/in_purodyuusu/screenshot_lesson_no_card.png.json b/res/sprites/jp/in_purodyuusu/screenshot_lesson_no_card.png.json new file mode 100644 index 0000000..20823ab --- /dev/null +++ b/res/sprites/jp/in_purodyuusu/screenshot_lesson_no_card.png.json @@ -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}}]} \ No newline at end of file diff --git a/res/sprites/jp/in_purodyuusu/screenshot_pdrink_max_confirm.png b/res/sprites/jp/in_purodyuusu/screenshot_pdrink_max_confirm.png new file mode 100644 index 0000000..ee4cefb Binary files /dev/null and b/res/sprites/jp/in_purodyuusu/screenshot_pdrink_max_confirm.png differ diff --git a/res/sprites/jp/in_purodyuusu/screenshot_pdrink_max_confirm.png.json b/res/sprites/jp/in_purodyuusu/screenshot_pdrink_max_confirm.png.json new file mode 100644 index 0000000..6792900 --- /dev/null +++ b/res/sprites/jp/in_purodyuusu/screenshot_pdrink_max_confirm.png.json @@ -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}}]} \ No newline at end of file