Merge branch 'refactor/produce-start'

This commit is contained in:
XcantloadX 2025-06-14 20:45:58 +08:00
commit b0e77e2173
19 changed files with 543 additions and 76 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

View File

@ -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}}]}

View File

@ -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

View File

@ -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}}]}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

277
kotonebot/backend/loop.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -11,7 +11,7 @@ from kotonebot.primitives import RectTuple, Rect
from kotonebot.kaa.game_ui import Scrollable
from kotonebot import device, action
from kotonebot.util import cv2_imread
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__)
@ -107,8 +107,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
@ -147,6 +171,6 @@ def locate_idol(skin_id: str):
# # 使用新函数绘制预览图
# preview_img = draw_idol_preview(img, rects, db, path)
if __name__ == '__main__':
locate_idol('i_card-skin-fktn-3-006')

View File

@ -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)

View File

@ -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,13 +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)
from kotonebot.backend.context import init_context
from kotonebot.kaa.common import BaseConfig
from kotonebot.kaa.main import Kaa
conf().produce.enabled = True
conf().produce.mode = 'pro'

View File

@ -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):