feat(*): 优化培育开始流程

1. SimpleDispatcher 类新增支持 click() 点击指定区域,与 until() 退出条件
2. Context* 类中的 wait_* 系列方法支持在手动截图模式中使用
3. 新增 button_state() 函数,用于判断一个游戏 UI 按钮是否禁用
4. 优化培育开始流程
This commit is contained in:
XcantloadX 2025-02-11 20:09:46 +08:00
parent 4bddee0959
commit f1a05e8cfb
9 changed files with 190 additions and 73 deletions

View File

@ -159,6 +159,13 @@ def warn_manual_screenshot_mode(name: str, alternative: str):
KotonebotWarning
)
def is_manual_screenshot_mode() -> bool:
"""
检查当前是否处于手动截图模式
"""
mode = ContextStackVars.ensure_current().screenshot_mode
return mode == 'manual' or mode == 'manual-inherit'
class ContextGlobalVars:
def __init__(self):
self.auto_collect: bool = False
@ -317,10 +324,12 @@ class ContextOcr:
"""
等待指定文本出现
"""
warn_manual_screenshot_mode("expect_wait", "find()")
is_manual = is_manual_screenshot_mode()
start_time = time.time()
while True:
if is_manual:
device.screenshot()
result = self.find(pattern, rect=rect, hint=hint)
if result is not None:
@ -342,10 +351,12 @@ class ContextOcr:
"""
等待指定文本出现
"""
warn_manual_screenshot_mode("wait_for", "find()")
is_manual = is_manual_screenshot_mode()
start_time = time.time()
while True:
if is_manual:
device.screenshot()
result = self.find(pattern, rect=rect, hint=hint)
if result is not None:
self.context.device.last_find = result.original_rect if result else None
@ -378,10 +389,12 @@ class ContextImage:
"""
等待指定图像出现
"""
warn_manual_screenshot_mode("wait_for", "find()")
is_manual = is_manual_screenshot_mode()
start_time = time.time()
while True:
if is_manual:
device.screenshot()
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
if ret is not None:
self.context.device.last_find = ret
@ -404,7 +417,7 @@ class ContextImage:
"""
等待指定图像中的任意一个出现
"""
warn_manual_screenshot_mode("wait_for_any", "find()")
is_manual = is_manual_screenshot_mode()
if masks is None:
_masks = [None] * len(templates)
@ -412,6 +425,8 @@ class ContextImage:
_masks = masks
start_time = time.time()
while True:
if is_manual:
device.screenshot()
for template, mask in zip(templates, _masks):
if self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored):
return True
@ -433,10 +448,12 @@ class ContextImage:
"""
等待指定图像出现
"""
warn_manual_screenshot_mode("expect_wait", "find()")
is_manual = is_manual_screenshot_mode()
start_time = time.time()
while True:
if is_manual:
device.screenshot()
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
if ret is not None:
self.context.device.last_find = ret
@ -459,7 +476,7 @@ class ContextImage:
"""
等待指定图像中的任意一个出现
"""
warn_manual_screenshot_mode("expect_wait_any", "find()")
is_manual = is_manual_screenshot_mode()
if masks is None:
_masks = [None] * len(templates)
@ -467,6 +484,8 @@ class ContextImage:
_masks = masks
start_time = time.time()
while True:
if is_manual:
device.screenshot()
for template, mask in zip(templates, _masks):
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
if ret is not None:

View File

@ -5,12 +5,13 @@ import inspect
from logging import Logger
from types import CodeType
from dataclasses import dataclass
from typing import Annotated, Any, Callable, Concatenate, TypeVar, ParamSpec, Literal, Protocol, cast
from typing import Annotated, Any, Callable, Concatenate, Sequence, TypeVar, ParamSpec, Literal, Protocol, cast
from typing_extensions import Self
from dataclasses import dataclass
from kotonebot.backend.ocr import StringMatchFunction
from kotonebot.backend.util import Rect, is_rect
from .core import Image
@ -118,7 +119,7 @@ class ClickParams:
finish: bool = False
log: str | None = None
class Click:
class ClickCenter:
def __init__(self, sd: 'SimpleDispatcher', target: Image | str | StringMatchFunction | Literal['center'], *, params: ClickParams = ClickParams()):
self.target = target
self.params = params
@ -182,6 +183,56 @@ class ClickText:
if self.params.finish:
self.sd.finished = True
class ClickRect:
def __init__(self, sd: 'SimpleDispatcher', rect: Rect, *, params: ClickParams = ClickParams()):
self.rect = rect
self.params = params
self.sd = sd
def __call__(self):
from kotonebot import device
if device.click(self.rect):
if self.params.log:
self.sd.logger.info(self.params.log)
if self.params.finish:
self.sd.finished = True
class UntilText:
def __init__(
self,
sd: 'SimpleDispatcher',
text: str | StringMatchFunction,
*,
rect: Rect | None = None
):
self.text = text
self.sd = sd
self.rect = rect
def __call__(self):
from kotonebot import ocr
if ocr.find(self.text, rect=self.rect):
self.sd.finished = True
class UntilImage:
def __init__(
self,
sd: 'SimpleDispatcher',
image: Image,
*,
rect: Rect | None = None
):
self.image = image
self.sd = sd
self.rect = rect
def __call__(self):
from kotonebot import image
if self.rect:
logger.warning(f'UntilImage with rect is deprecated. Use UntilText instead.')
if image.find(self.image):
self.sd.finished = True
class SimpleDispatcher:
def __init__(self, name: str, *, interval: float = 0.2):
self.name = name
@ -193,7 +244,7 @@ class SimpleDispatcher:
def click(
self,
target: Image | str | StringMatchFunction | Literal['center'],
target: Image | StringMatchFunction | Literal['center'] | Rect,
*,
finish: bool = False,
log: str | None = None
@ -201,8 +252,14 @@ class SimpleDispatcher:
params = ClickParams(finish=finish, log=log)
if isinstance(target, Image):
self.blocks.append(ClickImage(self, target, params=params))
else:
elif is_rect(target):
self.blocks.append(ClickRect(self, target, params=params))
elif callable(target):
self.blocks.append(ClickText(self, target, params=params))
elif target == 'center':
self.blocks.append(ClickCenter(self, target='center', params=params))
else:
raise ValueError(f'Invalid target: {target}')
return self
def click_any(
@ -216,6 +273,18 @@ class SimpleDispatcher:
self.blocks.append(ClickImageAny(self, target, params))
return self
def until(
self,
text: StringMatchFunction | Image,
*,
rect: Rect | None = None
):
if isinstance(text, Image):
self.blocks.append(UntilImage(self, text, rect=rect))
else:
self.blocks.append(UntilText(self, text, rect=rect))
return self
def run(self):
from kotonebot import device, sleep
while True:

View File

@ -6,7 +6,7 @@ import logging
import cProfile
from importlib import resources
from functools import lru_cache
from typing import Literal, Callable, TYPE_CHECKING
from typing import Literal, Callable, TYPE_CHECKING, TypeGuard
import cv2
from cv2.typing import MatLike
@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
Rect = typing.Sequence[int]
"""左上X, 左上Y, 宽度, 高度"""
def is_rect(rect: typing.Any) -> bool:
def is_rect(rect: typing.Any) -> TypeGuard[Rect]:
return isinstance(rect, typing.Sequence) and len(rect) == 4 and all(isinstance(i, int) for i in rect)
def crop(img: MatLike, /, x1: float = 0, y1: float = 0, x2: float = 1, y2: float = 1) -> MatLike:
@ -210,7 +210,7 @@ class Countdown:
self.start_time = time.time()
class Interval:
def __init__(self, seconds: float):
def __init__(self, seconds: float = 0.3):
self.seconds = seconds
self.start_time = time.time()
self.last_wait_time = 0
@ -223,6 +223,9 @@ class Interval:
self.last_wait_time = time.time() - self.start_time
self.start_time = time.time()
def reset(self):
self.start_time = time.time()
package_mode: Literal['wheel', 'standalone'] | None = None
def res_path(path: str) -> str:
"""

View File

@ -429,7 +429,8 @@ def practice():
# 结束动画
logger.info("CLEAR/PERFECT not found. Practice finished.")
(SimpleDispatcher('practice.end')
.click(contains("上昇"), finish=True, log="Click to finish 上昇 ")
.click(contains("上昇"), finish=True, log="Click to finish 上昇")
.until(contains("審査基準"))
.click('center')
).run()
@ -846,14 +847,14 @@ if __name__ == '__main__':
# practice()
# week_final_exam()
exam('final')
produce_end()
# exam('final')
# produce_end()
# hajime_pro(start_from=15)
# exam('mid')
# stage = (detect_regular_produce_scene())
# hajime_regular_from_stage(stage)
stage = (detect_regular_produce_scene())
hajime_regular_from_stage(stage)
# click_recommended_card(card_count=skill_card_count())
# exam('mid')

View File

@ -0,0 +1,31 @@
from typing import Literal
from cv2.typing import MatLike
from kotonebot import action, device, color, image
from kotonebot.backend.util import Rect
from kotonebot.backend.core import Image
@action('按钮是否禁用', screenshot_mode='manual-inherit')
def button_state(*, target: Image | None = None, rect: Rect | None = None) -> bool | None:
"""
判断按钮是否处于禁用状态
:param rect: 按钮的矩形区域必须包括文字或图标部分
:param target: 按钮目标模板
"""
img = device.screenshot()
if rect is not None:
_rect = rect
elif target is not None:
result = image.find(target)
if result is None:
return None
_rect = result.rect
else:
raise ValueError('Either rect or target must be provided.')
if color.find_rgb('#babcbd', rect=_rect):
return False
elif color.find_rgb('#ffffff', rect=_rect):
return True
else:
raise ValueError(f'Unknown button state: {img}')

View File

@ -2,14 +2,16 @@ import logging
from itertools import cycle
from typing import Optional
from kotonebot.backend.dispatch import SimpleDispatcher
from kotonebot.backend.util import Countdown, Interval
from kotonebot.ui import user
from . import R
from .common import conf, PIdol
from .actions.loading import wait_loading_end
from .game_ui import button_state
from .actions.scenes import at_home, goto_home
from .actions.in_purodyuusu import hajime_regular
from kotonebot import device, image, ocr, task, action, sleep, equals, contains
from .actions.scenes import loading, at_home, goto_home
from kotonebot import device, image, ocr, task, action, sleep, equals, contains, Rect
logger = logging.getLogger(__name__)
@ -33,7 +35,7 @@ def unify(arr: list[int]):
i = j
return result
@action('选择P偶像')
@action('选择P偶像', screenshot_mode='manual-inherit')
def select_idol(target_titles: list[str] | PIdol):
"""
选择目标P偶像
@ -113,8 +115,8 @@ def resume_produce():
logger.info('Click resume button.')
device.click(image.expect_wait(R.Produce.ButtonResume))
@action('执行培育')
def do_produce(idol: PIdol | None = None):
@action('执行培育', screenshot_mode='manual-inherit')
def do_produce(idol: PIdol):
"""
进行培育流程
@ -125,68 +127,51 @@ def do_produce(idol: PIdol | None = None):
"""
if not at_home():
goto_home()
device.screenshot()
# 有进行中培育的情况
if ocr.find(contains('プロデュース中'), rect=R.Produce.BoxProduceOngoing):
logger.info('Ongoing produce found. Try to resume produce.')
resume_produce()
return
# [screenshots/produce/home.png]
device.click(image.expect_wait(R.Produce.ButtonProduce))
sleep(0.3)
wait_loading_end()
# [screenshots/produce/regular_or_pro.png]
# device.click(image.expect_wait(R.Produce.ButtonRegular))
# 解锁 PRO 和解锁 PRO 前的 REGULAR 字体大小好像不一样。这里暂时用 OCR 代替
# TODO: 截图比较解锁 PRO 前和解锁后 REGULAR 的文字图像
device.click(ocr.expect_wait('REGULAR'))
sleep(0.3)
wait_loading_end()
# 选择 PIdol [screenshots/produce/select_p_idol.png]
if idol:
select_idol(idol.value)
elif conf().produce.idols:
select_idol(conf().produce.idols[0].value) # TODO: 支持多次培育
else:
logger.warning('No PIdol specified. Using default idol.')
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
sleep(0.1)
# 选择支援卡 自动编成 [screenshots/produce/select_support_card.png]
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
sleep(0.1)
device.click(image.expect_wait(R.Common.ButtonConfirm, colored=True))
sleep(1.3)
# 0. 进入培育页面
(SimpleDispatcher('enter_produce')
.click(R.Produce.ButtonProduce)
.click(contains('REGULAR'))
.until(R.Produce.ButtonPIdolOverview)
).run()
# 1. 选择 PIdol [screenshots/produce/select_p_idol.png]
select_idol(idol.value)
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
# 选择回忆 自动编成 [screenshots/produce/select_memory.png]
# 2. 选择支援卡 自动编成 [screenshots/produce/select_support_card.png]
ocr.expect_wait(contains('サポート'), rect=R.Produce.BoxStepIndicator)
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
sleep(1.3)
device.click(image.expect_wait(R.Common.ButtonConfirm, colored=True))
sleep(0.1)
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
sleep(0.6)
# 不租赁回忆提示弹窗 [screenshots/produce/no_rent_memory_dialog.png]
with device.pinned():
if image.find(R.Produce.TextRentAvailable):
device.click(image.expect(R.Common.ButtonNextNoIcon))
sleep(0.3)
# 选择道具 [screenshots/produce/select_end.png]
# 3. 选择回忆 自动编成 [screenshots/produce/select_memory.png]
ocr.expect_wait(contains('メモリー'), rect=R.Produce.BoxStepIndicator)
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
device.screenshot()
(SimpleDispatcher('do_produce.step_3')
.click(R.Common.ButtonNextNoIcon)
.click(R.Common.ButtonConfirm)
.until(contains('開始確認'), rect=R.Produce.BoxStepIndicator)
).run()
# 4. 选择道具 [screenshots/produce/select_end.png]
if conf().produce.use_note_boost:
device.click(image.expect_wait(R.Produce.CheckboxIconNoteBoost))
sleep(0.2)
if conf().produce.use_pt_boost:
device.click(image.expect_wait(R.Produce.CheckboxIconSupportPtBoost))
sleep(0.2)
device.click(image.expect_wait(R.Produce.ButtonProduceStart))
sleep(0.5)
# while not loading():
# # 跳过交流设置 [screenshots/produce/skip_commu.png]
# with device.pinned():
# if image.find(R.Produce.RadioTextSkipCommu):
# device.click()
# sleep(0.2)
# if image.find(R.Common.ButtonConfirmNoIcon):
# device.click()
# wait_loading_end()
# 5. 相关设置弹窗 [screenshots/produce/skip_commu.png]
cd = Countdown(5)
while not cd.expired():
device.screenshot()
if image.find(R.Produce.RadioTextSkipCommu):
device.click()
if image.find(R.Common.ButtonConfirmNoIcon):
device.click()
hajime_regular()
@task('培育')
@ -219,10 +204,18 @@ def produce_task(count: Optional[int] = None, idols: Optional[list[PIdol]] = Non
end_time = time.time()
logger.info(f"Produce time used: {format_time(end_time - start_time)}")
@action('测试')
def a():
ocr.expect_wait(contains('メモリー'), rect=R.Produce.BoxStepIndicator)
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
device.click(image.expect_wait(R.Common.ButtonConfirm, colored=True))
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
if __name__ == '__main__':
import logging
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)
do_produce()
do_produce(conf().produce.idols[0])
# a()
# select_idol(PIdol.藤田ことね_学園生活)

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

View File

@ -0,0 +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}}]}

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}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"}]}
{"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.BoxStepIndicator","displayName":"培育准备页面 当前步骤","type":"hint-box","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":4,"y1":11,"x2":405,"y2":99}}]}