feat(task): 优化了培育开始的逻辑,修复若干 bug
1. 修复了进入难度选择页面时,若当前为 NIA 培育,不会自动切换到 Hajime 培育的问题。 2. 修复了因 OCR 识别失败导致的无法选择难度问题,同时提高了识别速度。 3. 修复了因网络速度过慢导致脚本卡在选择回忆编成上。 4. 现在若默认选中的偶像已是目标偶像,不会再尝试重复选择。 Fixed #44.
This commit is contained in:
parent
3e675296d8
commit
86313ec52a
Binary file not shown.
After Width: | Height: | Size: 885 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e":{"name":"Produce.LogoNia","displayName":"NIA LOGO (NEXT IDOL AUDITION)","type":"template","annotationId":"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e","useHintRect":false},"48a458a9-b6cf-4199-850e-78f679f4f337":{"name":"Produce.PointNiaToHajime","displayName":"NIA 左侧翻页箭头","type":"hint-point","annotationId":"48a458a9-b6cf-4199-850e-78f679f4f337","useHintRect":false}},"annotations":[{"id":"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e","type":"rect","data":{"x1":195,"y1":424,"x2":540,"y2":466}},{"id":"48a458a9-b6cf-4199-850e-78f679f4f337","type":"point","data":{"x":34,"y":596}}]}
|
|
@ -1 +1 @@
|
|||
{"definitions":{"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe":{"name":"Produce.BoxModeButtons","displayName":"培育模式选择按钮","type":"hint-box","annotationId":"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe","useHintRect":false}},"annotations":[{"id":"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe","type":"rect","data":{"x1":7,"y1":818,"x2":713,"y2":996}}]}
|
||||
{"definitions":{"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743":{"name":"Produce.ButtonHajime0Regular","displayName":"","type":"template","annotationId":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","useHintRect":false},"55f7db71-0a18-4b3d-b847-57959b8d2e32":{"name":"Produce.ButtonHajime0Pro","displayName":"","type":"template","annotationId":"55f7db71-0a18-4b3d-b847-57959b8d2e32","useHintRect":false}},"annotations":[{"id":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","type":"rect","data":{"x1":145,"y1":859,"x2":314,"y2":960}},{"id":"55f7db71-0a18-4b3d-b847-57959b8d2e32","type":"rect","data":{"x1":434,"y1":857,"x2":545,"y2":961}}]}
|
Binary file not shown.
After Width: | Height: | Size: 791 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"3b473fe6-e147-477f-b088-9b8fb042a4f6":{"name":"Produce.ButtonHajime1Regular","displayName":"","type":"template","annotationId":"3b473fe6-e147-477f-b088-9b8fb042a4f6","useHintRect":false},"2ededcf5-1d80-4e2a-9c83-2a31998331ce":{"name":"Produce.ButtonHajime1Pro","displayName":"","type":"template","annotationId":"2ededcf5-1d80-4e2a-9c83-2a31998331ce","useHintRect":false},"24e99232-9434-457f-a9a0-69dd7ecf675f":{"name":"Produce.ButtonHajime1Master","displayName":"","type":"template","annotationId":"24e99232-9434-457f-a9a0-69dd7ecf675f","useHintRect":false},"aca9e953-1955-46eb-920c-77b1750bcb34":{"name":"Produce.PointHajimeToNia","displayName":"Hajime 右侧翻页箭头","type":"hint-point","annotationId":"aca9e953-1955-46eb-920c-77b1750bcb34","useHintRect":false},"e6b45405-cd9f-4c6e-a9f1-6ec953747c65":{"name":"Produce.LogoHajime","displayName":"Hajime LOGO 定期公演","type":"template","annotationId":"e6b45405-cd9f-4c6e-a9f1-6ec953747c65","useHintRect":false}},"annotations":[{"id":"3b473fe6-e147-477f-b088-9b8fb042a4f6","type":"rect","data":{"x1":65,"y1":867,"x2":214,"y2":950}},{"id":"2ededcf5-1d80-4e2a-9c83-2a31998331ce","type":"rect","data":{"x1":307,"y1":869,"x2":421,"y2":952}},{"id":"24e99232-9434-457f-a9a0-69dd7ecf675f","type":"rect","data":{"x1":521,"y1":863,"x2":657,"y2":951}},{"id":"aca9e953-1955-46eb-920c-77b1750bcb34","type":"point","data":{"x":680,"y":592}},{"id":"e6b45405-cd9f-4c6e-a9f1-6ec953747c65","type":"rect","data":{"x1":274,"y1":169,"x2":443,"y2":212}}]}
|
|
@ -1 +1 @@
|
|||
{"definitions":{"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd":{"name":"Produce.ButtonPIdolOverview","displayName":"Pアイドルー覧 P偶像列表展示","type":"template","annotationId":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","useHintRect":false},"44ba8515-4a60-42c9-8878-b42e4e34ee15":{"name":"Produce.TextStepIndicator1","displayName":"1. アイドル選択","type":"template","annotationId":"44ba8515-4a60-42c9-8878-b42e4e34ee15","useHintRect":false}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"},{"id":"44ba8515-4a60-42c9-8878-b42e4e34ee15","type":"rect","data":{"x1":18,"y1":32,"x2":168,"y2":66}}]}
|
||||
{"definitions":{"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd":{"name":"Produce.ButtonPIdolOverview","displayName":"Pアイドルー覧 P偶像列表展示","type":"template","annotationId":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","useHintRect":false},"44ba8515-4a60-42c9-8878-b42e4e34ee15":{"name":"Produce.TextStepIndicator1","displayName":"1. アイドル選択","type":"template","annotationId":"44ba8515-4a60-42c9-8878-b42e4e34ee15","useHintRect":false},"34606d7d-52c8-4cd1-b7f4-b31032f1fb70":{"name":"Produce.BoxSelectedIdol","displayName":"当前选中的偶像","type":"hint-box","annotationId":"34606d7d-52c8-4cd1-b7f4-b31032f1fb70","useHintRect":false,"description":"偶像选择界面当前选中的偶像"}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"},{"id":"44ba8515-4a60-42c9-8878-b42e4e34ee15","type":"rect","data":{"x1":18,"y1":32,"x2":168,"y2":66}},{"id":"34606d7d-52c8-4cd1-b7f4-b31032f1fb70","type":"rect","data":{"x1":149,"y1":783,"x2":317,"y2":1006}}]}
|
Binary file not shown.
After Width: | Height: | Size: 934 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"d3424d31-0502-4623-996e-f0194e5085ce":{"name":"Produce.EmptySupportCardSlot","displayName":"空支援卡槽位","type":"template","annotationId":"d3424d31-0502-4623-996e-f0194e5085ce","useHintRect":false}},"annotations":[{"id":"d3424d31-0502-4623-996e-f0194e5085ce","type":"rect","data":{"x1":481,"y1":844,"x2":692,"y2":962}}]}
|
Binary file not shown.
After Width: | Height: | Size: 531 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"f5c16d2f-ebc5-4617-9b96-971696af7c52":{"name":"Produce.TextAutoSet","displayName":"おまかせ編成","type":"template","annotationId":"f5c16d2f-ebc5-4617-9b96-971696af7c52","useHintRect":false}},"annotations":[{"id":"f5c16d2f-ebc5-4617-9b96-971696af7c52","type":"rect","data":{"x1":56,"y1":919,"x2":257,"y2":957}}]}
|
Binary file not shown.
After Width: | Height: | Size: 499 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"74ec3510-583d-4a76-ac69-38480fbf1387":{"name":"Produce.TextRentAvailable","displayName":"レンタル可能","type":"template","annotationId":"74ec3510-583d-4a76-ac69-38480fbf1387","useHintRect":false}},"annotations":[{"id":"74ec3510-583d-4a76-ac69-38480fbf1387","type":"rect","data":{"x1":53,"y1":848,"x2":256,"y2":887}}]}
|
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
|
@ -0,0 +1,277 @@
|
|||
import time
|
||||
from functools import lru_cache, partial
|
||||
from typing import Callable, Any, overload, Literal, Generic, TypeVar, cast, get_args, get_origin
|
||||
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from kotonebot.util import Interval
|
||||
from kotonebot import device, image, ocr
|
||||
from kotonebot.backend.core import Image
|
||||
from kotonebot.backend.ocr import TextComparator
|
||||
from kotonebot.client.protocol import ClickableObjectProtocol
|
||||
|
||||
|
||||
class LoopAction:
|
||||
def __init__(self, loop: 'Loop', func: Callable[[], ClickableObjectProtocol | None]):
|
||||
self.loop = loop
|
||||
self.func = func
|
||||
self.result: ClickableObjectProtocol | None = None
|
||||
|
||||
@property
|
||||
def found(self):
|
||||
"""
|
||||
是否找到结果。若父 Loop 未在运行中,则返回 False。
|
||||
"""
|
||||
if not self.loop.running:
|
||||
return False
|
||||
return bool(self.result)
|
||||
|
||||
def __bool__(self):
|
||||
return self.found
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
重置 LoopAction,以复用此对象。
|
||||
"""
|
||||
self.result = None
|
||||
|
||||
def do(self):
|
||||
"""
|
||||
执行 LoopAction。
|
||||
:return: 执行结果。
|
||||
"""
|
||||
if not self.loop.running:
|
||||
return
|
||||
if self.loop.found_anything:
|
||||
# 本轮循环已执行任意操作,因此不需要再继续检测
|
||||
return
|
||||
self.result = self.func()
|
||||
if self.result:
|
||||
self.loop.found_anything = True
|
||||
|
||||
def click(self, *, at: tuple[int, int] | None = None):
|
||||
"""
|
||||
点击寻找结果。若结果为空,会跳过执行。
|
||||
|
||||
:return:
|
||||
"""
|
||||
if self.result:
|
||||
if at is not None:
|
||||
device.click(*at)
|
||||
else:
|
||||
device.click(self.result)
|
||||
|
||||
def call(self, func: Callable[[ClickableObjectProtocol], Any]):
|
||||
pass
|
||||
|
||||
|
||||
class Loop:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
timeout: float = 300,
|
||||
interval: float = 0.3,
|
||||
auto_screenshot: bool = True
|
||||
):
|
||||
self.running = True
|
||||
self.found_anything = False
|
||||
self.auto_screenshot = auto_screenshot
|
||||
"""
|
||||
是否在每次循环开始时(Loop.tick() 被调用时)截图。
|
||||
"""
|
||||
self.__last_loop: float = -1
|
||||
self.__interval = Interval(interval)
|
||||
self.screenshot: MatLike | None = None
|
||||
"""上次截图时的图像数据。"""
|
||||
|
||||
def __iter__(self):
|
||||
self.__interval.reset()
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if not self.running:
|
||||
raise StopIteration
|
||||
self.found_anything = False
|
||||
self.__last_loop = time.time()
|
||||
return self.tick()
|
||||
|
||||
def tick(self):
|
||||
self.__interval.wait()
|
||||
if self.auto_screenshot:
|
||||
self.screenshot = device.screenshot()
|
||||
self.__last_loop = time.time()
|
||||
self.found_anything = False
|
||||
return self
|
||||
|
||||
def exit(self):
|
||||
"""
|
||||
结束循环。
|
||||
"""
|
||||
self.running = False
|
||||
|
||||
@overload
|
||||
def when(self, condition: Image) -> LoopAction:
|
||||
...
|
||||
|
||||
@overload
|
||||
def when(self, condition: TextComparator) -> LoopAction:
|
||||
...
|
||||
|
||||
def when(self, condition: Any):
|
||||
"""
|
||||
判断某个条件是否成立。
|
||||
|
||||
:param condition:
|
||||
:return:
|
||||
"""
|
||||
if isinstance(condition, Image):
|
||||
func = partial(image.find, condition)
|
||||
elif isinstance(condition, TextComparator):
|
||||
func = partial(ocr.find, condition)
|
||||
else:
|
||||
raise ValueError('Invalid condition type.')
|
||||
la = LoopAction(self, func)
|
||||
la.reset()
|
||||
la.do()
|
||||
return la
|
||||
|
||||
def until(self, condition: Any):
|
||||
"""
|
||||
当满足指定条件时,结束循环。
|
||||
|
||||
等价于 ``loop.when(...).call(lambda _: loop.exit())``
|
||||
"""
|
||||
return self.when(condition).call(lambda _: self.exit())
|
||||
|
||||
def click_if(self, condition: Any, *, at: tuple[int, int] | None = None):
|
||||
"""
|
||||
检测指定对象是否出现,若出现,点击该对象或指定位置。
|
||||
|
||||
``click_if()`` 等价于 ``loop.when(...).click(...)``。
|
||||
|
||||
:param condition: 检测目标。
|
||||
:param at: 点击位置。若为 None,表示点击找到的目标。
|
||||
"""
|
||||
return self.when(condition).click(at=at)
|
||||
|
||||
StateType = TypeVar('StateType')
|
||||
class StatedLoop(Loop, Generic[StateType]):
|
||||
def __init__(
|
||||
self,
|
||||
states: list[Any] | None = None,
|
||||
initial_state: StateType | None = None,
|
||||
*,
|
||||
timeout: float = 300,
|
||||
interval: float = 0.3,
|
||||
auto_screenshot: bool = True
|
||||
):
|
||||
self.__tmp_states = states
|
||||
self.__tmp_initial_state = initial_state
|
||||
self.state: StateType
|
||||
super().__init__(timeout=timeout, interval=interval, auto_screenshot=auto_screenshot)
|
||||
|
||||
def __iter__(self):
|
||||
# __retrive_state_values() 只能在非 __init__ 中调用
|
||||
self.__retrive_state_values()
|
||||
return super().__iter__()
|
||||
|
||||
def __retrive_state_values(self):
|
||||
# HACK: __orig_class__ 是 undocumented 属性
|
||||
if not hasattr(self, '__orig_class__'):
|
||||
# 如果 Foo 不是以参数化泛型的方式实例化的,可能没有 __orig_class__
|
||||
if self.state is None:
|
||||
raise ValueError('Either specify `states` or use StatedLoop[Literal[...]] syntax.')
|
||||
else:
|
||||
generic_type_args = get_args(self.__orig_class__) # type: ignore
|
||||
if len(generic_type_args) != 1:
|
||||
raise ValueError('StatedLoop must have exactly one generic type argument.')
|
||||
state_values = get_args(generic_type_args[0])
|
||||
if not state_values:
|
||||
raise ValueError('StatedLoop must have at least one state value.')
|
||||
self.states = cast(tuple[StateType, ...], state_values)
|
||||
self.state = self.__tmp_initial_state or self.states[0]
|
||||
return state_values
|
||||
|
||||
|
||||
def StatedLoop2(states: StateType) -> StatedLoop[StateType]:
|
||||
state_values = get_args(states)
|
||||
return cast(StatedLoop[StateType], Loop())
|
||||
|
||||
if __name__ == '__main__':
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.backend.ocr import contains
|
||||
from kotonebot.backend.context import manual_context, init_context
|
||||
|
||||
# T = TypeVar('T')
|
||||
# class Foo(Generic[T]):
|
||||
# def get_literal_params(self) -> list | None:
|
||||
# """
|
||||
# 尝试获取泛型参数 T (如果它是 Literal 类型) 的参数列表。
|
||||
# """
|
||||
# # self.__orig_class__ 会是 Foo 的具体参数化类型,
|
||||
# # 例如 Foo[Literal['p0', 'p1', 'p2', 'p3', 'ap']]
|
||||
# if not hasattr(self, '__orig_class__'):
|
||||
# # 如果 Foo 不是以参数化泛型的方式实例化的,可能没有 __orig_class__
|
||||
# return None
|
||||
#
|
||||
# # generic_type_args 是传递给 Foo 的类型参数元组
|
||||
# # 例如 (Literal['p0', 'p1', 'p2', 'p3', 'ap'],)
|
||||
# generic_type_args = get_args(self.__orig_class__)
|
||||
#
|
||||
# if not generic_type_args:
|
||||
# # Foo 没有类型参数
|
||||
# return None
|
||||
#
|
||||
# # T_type 是 Foo 的第一个类型参数
|
||||
# # 例如 Literal['p0', 'p1', 'p2', 'p3', 'ap']
|
||||
# t_type = generic_type_args[0]
|
||||
#
|
||||
# # 检查 T_type 是否是 Literal 类型
|
||||
# if get_origin(t_type) is Literal:
|
||||
# # literal_args 是 Literal 类型的参数元组
|
||||
# # 例如 ('p0', 'p1', 'p2', 'p3', 'ap')
|
||||
# literal_args = get_args(t_type)
|
||||
# return list(literal_args)
|
||||
# else:
|
||||
# # T 不是 Literal 类型
|
||||
# return None
|
||||
# f = Foo[Literal['p0', 'p1', 'p2', 'p3', 'ap']]()
|
||||
# values = f.get_literal_params()
|
||||
# 1
|
||||
|
||||
from typing_extensions import reveal_type
|
||||
slp = StatedLoop[Literal['p0', 'p1', 'p2', 'p3', 'ap']]()
|
||||
for l in slp:
|
||||
reveal_type(l.states)
|
||||
|
||||
# init_context()
|
||||
# manual_context().begin()
|
||||
# for l in Loop():
|
||||
# l.when(R.Produce.ButtonUse).click()
|
||||
# l.when(R.Produce.ButtonRefillAP).click()
|
||||
# l.when(contains("123")).click()
|
||||
# l.click_if(contains("!23"), at=(1, 2))
|
||||
|
||||
# State = Literal['p0', 'p1', 'p2', 'p3', 'ap']
|
||||
# for sl in StatedLoop[State]():
|
||||
# match sl.state:
|
||||
# case 'p0':
|
||||
# sl.click_if(R.Produce.ButtonProduce)
|
||||
# sl.click_if(contains('master'))
|
||||
# sl.when(R.Produce.ButtonPIdolOverview).goto('p1')
|
||||
# # AP 不足
|
||||
# sl.when(R.Produce.TextAPInsufficient).goto('ap')
|
||||
# case 'ap':
|
||||
# pass
|
||||
# # p1: 选择偶像
|
||||
# case 'p1':
|
||||
# sl.call(lambda _: select_idol(idol_skin_id), once=True)
|
||||
# sl.when(R.Produce.TextAnotherIdolAvailableDialog).call(dialog.no)
|
||||
# sl.click_if(R.Common.ButtonNextNoIcon)
|
||||
# sl.until(R.Produce.TextStepIndicator2).goto('p2')
|
||||
# case 'p2':
|
||||
# sl.when(contains("123")).click()
|
||||
# case 'p3':
|
||||
# sl.click_if(contains("!23"), at=(1, 2))
|
||||
# case _:
|
||||
# assert_never(sl.state)
|
|
@ -1,45 +1,65 @@
|
|||
import logging
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot import device, image
|
||||
|
||||
def expect_yes():
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def expect_yes(*, msg: str | None = None):
|
||||
"""
|
||||
点击对话框上的✔️按钮。若不存在,会等待其出现,直至超时异常。
|
||||
|
||||
前置条件:当前打开了任意对话框\n
|
||||
结束状态:点击了肯定意义按钮(✔️图标,橙色背景)后瞬间
|
||||
|
||||
:param msg: 成功点击后输出的日志信息。信息中的动词建议使用过去式。
|
||||
"""
|
||||
device.click(image.expect(R.Common.IconButtonCheck))
|
||||
if msg is not None:
|
||||
logger.debug(msg)
|
||||
|
||||
def yes() -> bool:
|
||||
def yes(*, msg: str | None = None) -> bool:
|
||||
"""
|
||||
点击对话框上的✔️按钮。
|
||||
|
||||
前置条件:当前打开了任意对话框\n
|
||||
结束状态:点击了肯定意义按钮(✔️图标,橙色背景)后瞬间
|
||||
|
||||
:param msg: 成功点击后输出的日志信息。信息中的动词建议使用过去式。
|
||||
"""
|
||||
if image.find(R.Common.IconButtonCheck):
|
||||
device.click()
|
||||
if msg is not None:
|
||||
logger.debug(msg)
|
||||
return True
|
||||
return False
|
||||
|
||||
def expect_no():
|
||||
def expect_no(*, msg: str | None = None):
|
||||
"""
|
||||
点击对话框上的✖️按钮。若不存在,会等待其出现,直至超时异常。
|
||||
|
||||
前置条件:当前打开了任意对话框\n
|
||||
结束状态:点击了否定意义按钮(✖️图标,白色背景)后瞬间
|
||||
|
||||
:param msg: 成功点击后输出的日志信息。信息中的动词建议使用过去式。
|
||||
"""
|
||||
device.click(image.expect(R.Common.IconButtonCross))
|
||||
if msg is not None:
|
||||
logger.debug(msg)
|
||||
|
||||
def no():
|
||||
def no(*, msg: str | None = None):
|
||||
"""
|
||||
点击对话框上的✖️按钮。
|
||||
|
||||
前置条件:当前打开了任意对话框\n
|
||||
结束状态:点击了否定意义按钮(✖️图标,白色背景)后瞬间
|
||||
|
||||
:param msg: 成功点击后输出的日志信息。信息中的动词建议使用过去式。
|
||||
"""
|
||||
if image.find(R.Common.IconButtonCross):
|
||||
device.click()
|
||||
if msg is not None:
|
||||
logger.debug(msg)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ from kotonebot.kaa.util import paths
|
|||
from kotonebot.primitives import RectTuple, Rect
|
||||
from kotonebot.kaa.game_ui import Scrollable
|
||||
from kotonebot import device, action
|
||||
from kotonebot.kaa.image_db import ImageDatabase, HistDescriptor, FileDataSource
|
||||
from kotonebot.kaa.image_db import ImageDatabase, HistDescriptor, FileDataSource, DatabaseQueryResult
|
||||
from kotonebot.backend.preprocessor import HsvColorsRemover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -106,8 +106,32 @@ def idols_db() -> ImageDatabase:
|
|||
_db = ImageDatabase(FileDataSource(str(path)), db_path, HistDescriptor(8), name='idols')
|
||||
return _db
|
||||
|
||||
def match_idol(skin_id: str, idol_img: MatLike) -> DatabaseQueryResult | None:
|
||||
"""
|
||||
将给定图像与指定偶像 ID 进行匹配。
|
||||
|
||||
:param skin_id: 偶像 ID。
|
||||
:param idol_img: 待匹配偶像图像。
|
||||
:return: 若匹配成功,则返回匹配结果,否则返回 None。
|
||||
"""
|
||||
db = idols_db()
|
||||
match = db.match(idol_img, 20)
|
||||
if match and match.key.startswith(skin_id):
|
||||
return match
|
||||
else:
|
||||
return None
|
||||
|
||||
@action('定位偶像', screenshot_mode='manual-inherit')
|
||||
def locate_idol(skin_id: str):
|
||||
def locate_idol(skin_id: str) -> Rect | None:
|
||||
"""
|
||||
定位并选中指定偶像。
|
||||
|
||||
前置条件:位于偶像总览界面。\n
|
||||
结束状态:位于偶像总览界面。
|
||||
|
||||
:param skin_id: 目标偶像的 Skin ID
|
||||
:return: 若成功,返回目标偶像的范围 (x, y, w, h),否则返回 None。
|
||||
"""
|
||||
device.screenshot()
|
||||
logger.info('Locating idol %s', skin_id)
|
||||
x, y, w, h = R.Produce.BoxIdolOverviewIdols.xywh
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import cv2
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from kotonebot import action, image, device
|
||||
from kotonebot.backend.core import Image
|
||||
from kotonebot.backend.image import TemplateMatchResult
|
||||
|
||||
|
||||
def primary_button_state(img: MatLike) -> Optional[bool]:
|
||||
"""
|
||||
分析按钮图像并根据红色通道直方图返回按钮状态
|
||||
|
||||
:param img: 输入的按钮图像 (BGR格式)
|
||||
:return: True - 启用状态
|
||||
False - 禁用状态
|
||||
None - 未知状态或输入无效
|
||||
"""
|
||||
# 确保图像有效
|
||||
if img is None or img.size == 0:
|
||||
return None
|
||||
|
||||
# 计算红色通道直方图(五箱)
|
||||
_, _, r = cv2.split(img)
|
||||
hist = cv2.calcHist([r], [0], None, [5], [0, 256])
|
||||
# 归一化并找出红色集中在哪一箱
|
||||
hist = hist.ravel() / hist.sum()
|
||||
max_idx = np.argmax(hist)
|
||||
|
||||
if max_idx == 3:
|
||||
return False
|
||||
elif max_idx == 4:
|
||||
return True
|
||||
else:
|
||||
return None
|
||||
|
||||
@action('寻找按钮', screenshot_mode='manual-inherit')
|
||||
def find_button(template: MatLike | Image, state: Optional[bool] = None) -> Optional[bool]:
|
||||
"""
|
||||
在图像中寻找按钮并返回其状态
|
||||
|
||||
:param template: 按钮模板图像 (BGR格式)
|
||||
:param state: 按钮状态 (True - 启用, False - 禁用, None - 任意)
|
||||
:return: True - 启用状态
|
||||
False - 禁用状态
|
||||
None - 未找到按钮或状态未知
|
||||
"""
|
||||
img = device.screenshot()
|
||||
result = image.find(template)
|
||||
if result is None:
|
||||
return None
|
||||
x, y, w, h = result.rect.xywh
|
||||
button_img = img[y:y+h, x:x+w]
|
||||
return primary_button_state(button_img)
|
|
@ -4,15 +4,14 @@ from typing import Optional, Literal
|
|||
from typing_extensions import assert_never
|
||||
|
||||
from kotonebot.ui import user
|
||||
from kotonebot.util import Countdown, Interval
|
||||
from kotonebot.backend.context.context import wait
|
||||
from kotonebot.backend.dispatch import SimpleDispatcher
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.game_ui import dialog
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot.kaa.game_ui.idols_overview import locate_idol
|
||||
from kotonebot.backend.loop import Loop, StatedLoop
|
||||
from kotonebot.util import Countdown, Interval, Throttler
|
||||
from kotonebot.kaa.game_ui.primary_button import find_button
|
||||
from kotonebot.kaa.game_ui.idols_overview import locate_idol, match_idol
|
||||
from ..produce.in_purodyuusu import hajime_pro, hajime_regular, hajime_master, resume_pro_produce, resume_regular_produce, \
|
||||
resume_master_produce
|
||||
from kotonebot import device, image, ocr, task, action, sleep, contains, regex
|
||||
|
@ -193,10 +192,15 @@ def do_produce(
|
|||
前置条件:可导航至首页的任意页面\n
|
||||
结束状态:游戏首页\n
|
||||
|
||||
:param idol: 要培育的偶像。如果为 None,则使用配置文件中的偶像。
|
||||
:param memory_set_index: 回忆编成编号。
|
||||
:param idol_skin_id: 要培育的偶像。如果为 None,则使用配置文件中的偶像。
|
||||
:param mode: 培育模式。
|
||||
:return: 是否因为 AP 不足而跳过本次培育。
|
||||
:raises ValueError: 如果 `memory_set_index` 不在 [1, 10] 的范围内。
|
||||
"""
|
||||
if memory_set_index is not None and not 1 <= memory_set_index <= 10:
|
||||
raise ValueError('`memory_set_index` must be in range [1, 10].')
|
||||
|
||||
if not at_home():
|
||||
goto_home()
|
||||
|
||||
|
@ -208,16 +212,28 @@ def do_produce(
|
|||
return True
|
||||
|
||||
# 0. 进入培育页面
|
||||
mode_text = mode.upper()
|
||||
if mode_text == 'MASTER':
|
||||
mode_text = 'MASTER|MIASTER'
|
||||
logger.info(f'Enter produce page. Mode: {mode_text}')
|
||||
result = (SimpleDispatcher('enter_produce')
|
||||
.click(R.Produce.ButtonProduce)
|
||||
.click(regex(mode_text))
|
||||
.until(R.Produce.ButtonPIdolOverview, result=True)
|
||||
.until(R.Produce.TextAPInsufficient, result=False)
|
||||
).run()
|
||||
logger.info(f'Enter produce page. Mode: {mode}')
|
||||
match mode:
|
||||
case 'regular':
|
||||
target_buttons = [R.Produce.ButtonHajime0Regular, R.Produce.ButtonHajime1Regular]
|
||||
case 'pro':
|
||||
target_buttons = [R.Produce.ButtonHajime0Pro, R.Produce.ButtonHajime1Pro]
|
||||
case 'master':
|
||||
target_buttons = [R.Produce.ButtonHajime1Master]
|
||||
case _:
|
||||
assert_never(mode)
|
||||
result = None
|
||||
for _ in Loop():
|
||||
if image.find(R.Produce.ButtonProduce):
|
||||
device.click()
|
||||
elif image.find_multi(target_buttons):
|
||||
device.click()
|
||||
elif image.find(R.Produce.ButtonPIdolOverview):
|
||||
result = True
|
||||
break
|
||||
elif image.find(R.Produce.TextAPInsufficient):
|
||||
result = False
|
||||
break
|
||||
if not result:
|
||||
if conf().produce.use_ap_drink:
|
||||
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_1.png]
|
||||
|
@ -230,7 +246,7 @@ def do_produce(
|
|||
device.click()
|
||||
elif image.find(R.Produce.ButtonRefillAP):
|
||||
device.click()
|
||||
elif ocr.find(contains(mode_text)):
|
||||
elif image.find_multi(target_buttons):
|
||||
device.click()
|
||||
elif image.find(R.Produce.ButtonPIdolOverview):
|
||||
break
|
||||
|
@ -240,49 +256,91 @@ def do_produce(
|
|||
logger.info('AP insufficient. Exiting produce.')
|
||||
device.click(image.expect_wait(R.InPurodyuusu.ButtonCancel))
|
||||
return False
|
||||
# 1. 选择 PIdol [screenshots/produce/screenshot_produce_start_1_p_idol.png]
|
||||
select_idol(idol_skin_id)
|
||||
it = Interval()
|
||||
while True:
|
||||
it.wait()
|
||||
device.screenshot()
|
||||
if image.find(R.Produce.TextAnotherIdolAvailableDialog):
|
||||
dialog.no()
|
||||
elif image.find(R.Common.ButtonNextNoIcon):
|
||||
device.click()
|
||||
if image.find(R.Produce.TextStepIndicator2):
|
||||
break
|
||||
# 2. 选择支援卡 自动编成 [screenshots/produce/screenshot_produce_start_2_support_card.png]
|
||||
image.expect_wait(R.Produce.TextStepIndicator2)
|
||||
it = Interval()
|
||||
while True:
|
||||
if image.find(R.Common.ButtonNextNoIcon, colored=True):
|
||||
device.click()
|
||||
break
|
||||
elif image.find(R.Produce.ButtonAutoSet):
|
||||
device.click()
|
||||
sleep(1)
|
||||
elif image.find(R.Common.ButtonConfirm, colored=True):
|
||||
device.click()
|
||||
device.screenshot()
|
||||
it.wait()
|
||||
# 3. 选择回忆 自动编成 [screenshots/produce/screenshot_produce_start_3_memory.png]
|
||||
image.expect_wait(R.Produce.TextStepIndicator3)
|
||||
# 自动编成
|
||||
if memory_set_index is not None and not 1 <= memory_set_index <= 10:
|
||||
raise ValueError('`memory_set_index` must be in range [1, 10].')
|
||||
if memory_set_index is None:
|
||||
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
|
||||
wait(0.5, before='screenshot')
|
||||
device.screenshot()
|
||||
# 指定编号
|
||||
else:
|
||||
select_set(memory_set_index)
|
||||
(SimpleDispatcher('do_produce.step_3')
|
||||
.until(R.Produce.TextStepIndicator4)
|
||||
.click(R.Common.ButtonNextNoIcon)
|
||||
.click(R.Common.IconButtonCheck)
|
||||
).run()
|
||||
|
||||
idol_located = False
|
||||
memory_set_selected = False
|
||||
support_auto_set_done = False
|
||||
next_throttler = Throttler(interval=4)
|
||||
for lp in StatedLoop[Literal[0, 1, 2, 3]]():
|
||||
if image.find(R.Produce.TextStepIndicator1):
|
||||
lp.state = 1
|
||||
|
||||
if lp.state == 0:
|
||||
pass
|
||||
# 1. 选择 PIdol [screenshots/produce/screenshot_produce_start_1_p_idol.png]
|
||||
if lp.state == 1:
|
||||
if image.find(R.Produce.TextStepIndicator2):
|
||||
lp.state = 2
|
||||
continue
|
||||
if lp.when(R.Produce.TextAnotherIdolAvailableDialog):
|
||||
dialog.no(msg='Closed another idol available dialog.')
|
||||
# 首先判断是否已选中目标偶像
|
||||
img = lp.screenshot
|
||||
x, y, w, h = R.Produce.BoxSelectedIdol.xywh
|
||||
if img is not None and match_idol(idol_skin_id, img[y:y+h, x:x+w]):
|
||||
logger.info('Idol %s selected.', idol_skin_id)
|
||||
idol_located = True
|
||||
# 如果没有,才选择
|
||||
if not idol_located:
|
||||
select_idol(idol_skin_id)
|
||||
idol_located = True
|
||||
|
||||
# 下一步「次へ」
|
||||
if idol_located and find_button(R.Common.ButtonNextNoIcon, True) and next_throttler.request():
|
||||
device.click()
|
||||
# 2. 选择支援卡 自动编成 [screenshots/produce/screenshot_produce_start_2_support_card.png]
|
||||
elif lp.state == 2:
|
||||
if image.find(R.Produce.TextStepIndicator3):
|
||||
lp.state = 3
|
||||
continue
|
||||
|
||||
# 下一步「次へ」
|
||||
if find_button(R.Common.ButtonNextNoIcon, True) and next_throttler.request():
|
||||
device.click()
|
||||
# 今天仍然有租用回忆次数提示(第三步的提示)
|
||||
# (第二步选完之后点「次へ」大概率会卡几秒钟,这个时候脚本很可能会重复点击,
|
||||
# 卡住时候的点击就会在第三步生效,出现这个提示。而此时脚本仍然处于第二步,
|
||||
# 这样就会报错,或者出现误自动编成。因此需要在第二步里处理掉这个对话框。
|
||||
# 理论上应该避免这种情况,但是没找到办法,只能这样 workaround 了。)
|
||||
elif image.find(R.Produce.TextRentAvailable):
|
||||
dialog.no(msg='Closed rent available dialog. (Step 2)')
|
||||
# 确认自动编成提示
|
||||
elif image.find(R.Produce.TextAutoSet):
|
||||
dialog.yes(msg='Confirmed auto set.')
|
||||
sleep(1) # 等对话框消失
|
||||
elif not support_auto_set_done and image.find(R.Produce.ButtonAutoSet):
|
||||
device.click()
|
||||
support_auto_set_done = True
|
||||
sleep(1)
|
||||
# 3. 选择回忆 自动编成 [screenshots/produce/screenshot_produce_start_3_memory.png]
|
||||
elif lp.state == 3:
|
||||
if image.find(R.Produce.TextStepIndicator4):
|
||||
break
|
||||
|
||||
# 确认自动编成提示
|
||||
if image.find(R.Produce.TextAutoSet):
|
||||
dialog.yes(msg='Confirmed auto set.')
|
||||
continue
|
||||
# 今天仍然有租用回忆次数提示
|
||||
elif image.find(R.Produce.TextRentAvailable):
|
||||
dialog.yes(msg='Confirmed rent available. (Step 3)')
|
||||
continue
|
||||
|
||||
if not memory_set_selected:
|
||||
# 自动编成
|
||||
if memory_set_index is None:
|
||||
lp.click_if(R.Produce.ButtonAutoSet)
|
||||
# 指定编号
|
||||
else:
|
||||
# dialog.no() # TODO: 这是什么?
|
||||
select_set(memory_set_index)
|
||||
memory_set_selected = True
|
||||
# 下一步「次へ」
|
||||
if find_button(R.Common.ButtonNextNoIcon, True) and next_throttler.request():
|
||||
device.click()
|
||||
continue
|
||||
else:
|
||||
assert False, f'Invalid state of {lp.state}.'
|
||||
|
||||
# 4. 选择道具 [screenshots/produce/screenshot_produce_start_4_end.png]
|
||||
# TODO: 如果道具不足,这里加入推送提醒
|
||||
|
@ -357,17 +415,9 @@ if __name__ == '__main__':
|
|||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
||||
logging.getLogger('kotonebot').setLevel(logging.DEBUG)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
import os
|
||||
from datetime import datetime
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
log_filename = datetime.now().strftime('logs/task-%y-%m-%d-%H-%M-%S.log')
|
||||
file_handler = logging.FileHandler(log_filename, encoding='utf-8')
|
||||
file_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s'))
|
||||
logging.getLogger().addHandler(file_handler)
|
||||
|
||||
import time
|
||||
from kotonebot.backend.context import init_context
|
||||
from kotonebot.kaa.common import BaseConfig
|
||||
from kotonebot.kaa.main import Kaa
|
||||
|
||||
init_context(config_type=BaseConfig)
|
||||
conf().produce.enabled = True
|
||||
|
|
|
@ -263,6 +263,38 @@ class Interval:
|
|||
def reset(self):
|
||||
self.start_time = time.time()
|
||||
|
||||
class Throttler:
|
||||
"""
|
||||
限流器,在循环中用于限制某操作的频率。
|
||||
|
||||
示例代码:
|
||||
```python
|
||||
while True:
|
||||
device.screenshot()
|
||||
if throttler.request() and image.find(...):
|
||||
do_something()
|
||||
```
|
||||
"""
|
||||
def __init__(self, interval: float, max_requests: int | None = None):
|
||||
self.max_requests = max_requests
|
||||
self.interval = interval
|
||||
self.last_request_time: float | None = None
|
||||
self.request_count = 0
|
||||
|
||||
def request(self) -> bool:
|
||||
"""
|
||||
检查是否允许请求。此函数立即返回,不会阻塞。
|
||||
|
||||
:return: 如果允许,返回 True,否则返回 False
|
||||
"""
|
||||
current_time = time.time()
|
||||
if self.last_request_time is None or current_time - self.last_request_time >= self.interval:
|
||||
self.last_request_time = current_time
|
||||
self.request_count = 0
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def lf_path(path: str) -> str:
|
||||
standalone = os.path.join('kotonebot-resource', path)
|
||||
if os.path.exists(standalone):
|
||||
|
|
Loading…
Reference in New Issue