diff --git a/assets/recruit_claim.png b/assets/recruit_claim.png new file mode 100644 index 0000000..e21af1d Binary files /dev/null and b/assets/recruit_claim.png differ diff --git a/assets/recruit_free.png b/assets/recruit_free.png new file mode 100644 index 0000000..fe8660f Binary files /dev/null and b/assets/recruit_free.png differ diff --git a/assets/recruit_gift_label.png b/assets/recruit_gift_label.png new file mode 100644 index 0000000..f7a4fd5 Binary files /dev/null and b/assets/recruit_gift_label.png differ diff --git a/assets/recruit_lamp_title.png b/assets/recruit_lamp_title.png new file mode 100644 index 0000000..19bb288 Binary files /dev/null and b/assets/recruit_lamp_title.png differ diff --git a/assets/recruit_reward.png b/assets/recruit_reward.png new file mode 100644 index 0000000..3518092 Binary files /dev/null and b/assets/recruit_reward.png differ diff --git a/assets/recruit_shop_header.png b/assets/recruit_shop_header.png new file mode 100644 index 0000000..6f7d686 Binary files /dev/null and b/assets/recruit_shop_header.png differ diff --git a/assets/recruit_task_button.png b/assets/recruit_task_button.png new file mode 100644 index 0000000..c107c16 Binary files /dev/null and b/assets/recruit_task_button.png differ diff --git a/assets/recruit_task_dot.png b/assets/recruit_task_dot.png new file mode 100644 index 0000000..482135e Binary files /dev/null and b/assets/recruit_task_dot.png differ diff --git a/automation/engine.py b/automation/engine.py index 1d423de..d9810d2 100644 --- a/automation/engine.py +++ b/automation/engine.py @@ -78,6 +78,37 @@ class Matcher: return 0.0, None return float(score), (x1 + x, y1 + y, w, h) + def locate_all(self, frame, spec, limit=20): + """Return non-overlapping template matches, ordered from top to bottom.""" + name = spec['template'] + if name not in self.cache: + p = self.assets / name + if not p.is_file(): + raise WorkflowError('缺少识别模板:' + name) + self.cache[name] = np.asarray(Image.open(p).convert('RGB')) + template = self.cache[name] + x1, y1, x2, y2 = spec['search'] + area = frame[y1:y2, x1:x2] + h, w = template.shape[:2] + if area.shape[0] < h or area.shape[1] < w: + raise WorkflowError('模板尺寸超出识别区域。') + scores = cv2.matchTemplate(area, template, cv2.TM_CCOEFF_NORMED) + threshold = spec.get('threshold', 0.86) + matches = [] + while len(matches) < limit: + _, score, _, (x, y) = cv2.minMaxLoc(scores) + if not math.isfinite(score) or score < threshold: + break + candidate = area[y:y + h, x:x + w] + difference = float(np.abs(candidate.astype(float) - template.astype(float)).mean()) + # Suppress this neighbourhood regardless of the colour check so + # a rejected partial overlap cannot repeat forever. + scores[max(0, y - h // 2):min(scores.shape[0], y + h // 2 + 1), + max(0, x - w // 2):min(scores.shape[1], x + w // 2 + 1)] = -1 + if difference <= spec.get('max_color_difference', 28): + matches.append((float(score), (x1 + x, y1 + y, w, h))) + return sorted(matches, key=lambda item: (item[1][1], item[1][0])) + def matches(self, frame, specs): return all(self.score(frame, s) >= s.get('threshold', 0.86) for s in specs) @@ -116,6 +147,27 @@ class Desktop: finally: self.api.FAILSAFE = saved_failsafe + def drag(self, start, end, region, screenshot_size, hold=0.2, duration=0.7): + """Perform a deliberate touch-like list swipe inside the game region.""" + from .permissions import mouse_permission_granted + if not mouse_permission_granted(): + raise WorkflowError('macOS 辅助功能权限未授予,无法发送有效鼠标拖拽。') + start_point = region.mouse_point(start, screenshot_size, tuple(self.api.size())) + end_point = region.mouse_point(end, screenshot_size, tuple(self.api.size())) + self.api.moveTo(*start_point, duration=0.2) + self.api.mouseDown() + try: + time.sleep(hold) + self.api.moveTo(*end_point, duration=duration) + time.sleep(hold) + finally: + saved = self.api.FAILSAFE + self.api.FAILSAFE = False + try: + self.api.mouseUp() + finally: + self.api.FAILSAFE = saved + class Runner: def __init__(self, desktop, matcher, region, log=print, stop=None): diff --git a/automation/recruit.py b/automation/recruit.py new file mode 100644 index 0000000..d76b7da --- /dev/null +++ b/automation/recruit.py @@ -0,0 +1,168 @@ +"""商城英雄招募的限时 UP 神灯任务与免费神灯领取。""" +import time + +from .engine import WorkflowError + + +class RecruitRunner: + def __init__(self, desktop, matcher, flow, region_provider, log=print, timeout=12): + self.desktop = desktop + self.matcher = matcher + self.flow = flow + self.region_provider = region_provider + self.log = log + self.timeout = timeout + + def wait(self, *names): + end = time.monotonic() + self.timeout + previous, stable = None, 0 + while time.monotonic() < end: + shot = self.desktop.screenshot() + region = self.region_provider(shot) + frame = self.matcher.frame(shot, region) + found = next((name for name in names + if self.matcher.matches(frame, self.flow['states'][name])), None) + stable = stable + 1 if found and found == previous else (1 if found else 0) + previous = found + if stable >= 2: + return found, shot, region, frame + time.sleep(.25) + raise WorkflowError('未识别到预期页面 ' + '/'.join(names) + ',已停止。') + + def observe(self): + shot = self.desktop.screenshot() + region = self.region_provider(shot) + return shot, region, self.matcher.frame(shot, region) + + def click(self, name, observation, hold=.8): + shot, region, _ = observation[-3:] + if self.region_provider(shot) != region: + raise WorkflowError('识别后游戏窗口移动,已停止,请重新运行。') + self.desktop.click(self.flow['clicks'][name], region, shot.size, hold=hold) + self.log('已执行:' + name) + + def click_location(self, location, observation): + shot, region, _ = observation[-3:] + x, y, width, height = location + self.desktop.click(((x + width / 2) / 562, (y + height / 2) / 1002), + region, shot.size, hold=.8) + + def swipe(self, name, observation): + shot, region, _ = observation[-3:] + start, end = self.flow['swipes'][name] + self.desktop.drag(start, end, region, shot.size) + time.sleep(self.flow.get('swipe_settle_seconds', .5)) + + def has_dot(self, frame, spec_name, search=None): + spec = dict(self.flow[spec_name]) + if search: + spec['search'] = search + score, _ = self.matcher.locate(frame, spec) + return score >= spec.get('threshold', .86) + + def task_buttons_with_dot(self, frame): + buttons = self.matcher.locate_all(frame, self.flow['task_button'], limit=8) + for _, location in buttons: + x, y, width, _ = location + # The red dot sits just above the task-button's top-right corner. + dot_search = [x + width - 32, max(0, y - 31), x + width + 8, y + 5] + if self.has_dot(frame, 'card_task_dot', dot_search): + yield location + + def dismiss_reward(self): + reward = self.wait('reward') + self.click('dismiss_reward', reward, hold=.2) + self.wait('lamp') + + def claim_visible_tasks(self): + claimed = 0 + empty_scrolls = 0 + while claimed < self.flow['limits']['task_claims']: + lamp = self.wait('lamp') + spec = self.flow['task_claim'] + matches = self.matcher.locate_all(lamp[3], spec, limit=8) + if matches: + _, location = matches[0] + self.click_location(location, lamp) + self.log('已领取神灯任务奖励。') + self.dismiss_reward() + claimed += 1 + empty_scrolls = 0 + continue + if empty_scrolls >= self.flow['limits']['task_scrolls']: + break + self.swipe('task_list_up', lamp) + empty_scrolls += 1 + return claimed + + def process_lamp(self): + lamp = self.wait('lamp') + if self.has_dot(lamp[3], 'task_tab_dot'): + self.click('task_tab', lamp) + self.wait('lamp') + self.claim_visible_tasks() + + lamp = self.wait('lamp') + if self.has_dot(lamp[3], 'gift_tab_dot'): + self.click('gift_tab', lamp) + gift = self.wait('gift') + score, location = self.matcher.locate(gift[3], self.flow['free_button']) + if score >= self.flow['free_button'].get('threshold', .86): + self.click_location(location, gift) + self.log('已领取免费神灯。') + self.dismiss_reward() + else: + self.log('神灯礼包没有可领取的免费神灯,跳过。') + + lamp = self.wait('lamp') + self.click('close_lamp', lamp, hold=.2) + time.sleep(.5) + + def return_home(self): + observation = self.observe() + self.click('shop_back', observation, hold=.2) + self.wait('home') + self.log('已确认返回首页。') + + def run(self, dry_run=False): + home = self.wait('home') + if dry_run: + self.log('首页识别通过。计划:商城 → 英雄招募限时 UP → 神灯任务/礼包 → 返回首页。未点击。') + return 'preview' + + self.click('shop', home, hold=.8) + shop = self.wait('shop') + for _ in range(self.flow['limits']['scroll_to_recruit']): + self.swipe('shop_down', shop) + shop = self.observe() + if self.matcher.locate_all(shop[2], self.flow['task_button'], limit=1): + break + + handled, blank_pages = 0, 0 + while handled < self.flow['limits']['cards']: + _, _, frame = self.observe() + task_button = next(self.task_buttons_with_dot(frame), None) + if task_button: + # Click the button itself, rather than its red dot. + x, y, width, height = task_button + shot, region, current = self.observe() + self.desktop.click(((x + width / 2) / 562, (y + height / 2) / 1002), + region, shot.size, hold=.8) + self.log('已打开限时 UP 卡片的神灯召唤。') + self.process_lamp() + handled += 1 + blank_pages = 0 + continue + if blank_pages >= self.flow['limits']['card_pages']: + break + # The card stack precedes 图腾寻宝. Pulling the list down shows + # earlier UP cards without ever touching the treasure buttons. + self.swipe('shop_down', self.observe()) + blank_pages += 1 + + self.return_home() + if handled: + self.log('已处理 %s 张限时 UP 卡片。' % handled) + return 'claimed' + self.log('没有带红点的限时 UP 任务卡片,未领取奖励。') + return 'no_card_dot' diff --git a/claim_recruit_signin.py b/claim_recruit_signin.py new file mode 100644 index 0000000..bdefd57 --- /dev/null +++ b/claim_recruit_signin.py @@ -0,0 +1,37 @@ +"""领取商城英雄招募限时 UP 的神灯任务与免费神灯。""" +import argparse +import sys +import time +from pathlib import Path + +from automation.engine import Matcher, load_flow +from automation.offers import MacGameDesktop +from automation.recruit import RecruitRunner + +ROOT = Path(__file__).resolve().parent + + +def main(): + parser = argparse.ArgumentParser(description='英雄招募签到:领取限时 UP 神灯任务和免费神灯') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + if sys.platform != 'darwin': + print('当前脚本仅完成 macOS 实机适配。', file=sys.stderr) + return 2 + try: + desktop = MacGameDesktop() + desktop.activate() + print('3 秒后开始;请保持游戏在前台。', flush=True) + time.sleep(3) + return_code = RecruitRunner( + desktop, Matcher(ROOT / 'assets'), load_flow(ROOT / 'workflows/recruit_signin.json'), + desktop.region_for).run(args.dry_run) + print('流程结果:' + return_code) + return 0 + except Exception as error: + print('流程停止:' + str(error), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/workflows/recruit_signin.json b/workflows/recruit_signin.json new file mode 100644 index 0000000..6b404c3 --- /dev/null +++ b/workflows/recruit_signin.json @@ -0,0 +1,31 @@ +{ + "name": "商城英雄招募限时 UP 神灯签到", + "states": { + "home": [{"template": "home.png", "search": [150, 730, 410, 865]}], + "shop": [{"template": "recruit_shop_header.png", "search": [50, 20, 510, 220], "threshold": 0.94}], + "lamp": [{"template": "recruit_lamp_title.png", "search": [130, 100, 430, 240], "threshold": 0.88}], + "gift": [{"template": "recruit_gift_label.png", "search": [70, 240, 280, 330], "threshold": 0.88}], + "reward": [{"template": "recruit_reward.png", "search": [100, 180, 460, 400], "threshold": 0.78, "max_color_difference": 40}] + }, + "clicks": { + "shop": [0.10, 0.94], + "task_tab": [0.37, 0.89], + "gift_tab": [0.65, 0.89], + "dismiss_reward": [0.50, 0.72], + "close_lamp": [0.915, 0.205], + "shop_back": [0.10, 0.94] + }, + "swipes": { + "shop_down": [[0.50, 0.36], [0.50, 0.78]], + "shop_up": [[0.50, 0.78], [0.50, 0.30]], + "task_list_up": [[0.50, 0.76], [0.50, 0.33]] + }, + "card_task_dot": {"template": "recruit_task_dot.png", "search": [440, 120, 540, 820], "threshold": 0.88, "max_color_difference": 25}, + "task_button": {"template": "recruit_task_button.png", "search": [430, 120, 540, 820], "threshold": 0.88, "max_color_difference": 25}, + "task_tab_dot": {"template": "recruit_task_dot.png", "search": [220, 830, 300, 900], "threshold": 0.80, "max_color_difference": 25}, + "gift_tab_dot": {"template": "recruit_task_dot.png", "search": [390, 830, 480, 900], "threshold": 0.90, "max_color_difference": 25}, + "task_claim": {"template": "recruit_claim.png", "search": [330, 240, 510, 820], "threshold": 0.90, "max_color_difference": 25}, + "free_button": {"template": "recruit_free.png", "search": [330, 240, 510, 420], "threshold": 0.90, "max_color_difference": 25}, + "limits": {"scroll_to_recruit": 3, "cards": 12, "card_pages": 3, "task_claims": 20, "task_scrolls": 3}, + "swipe_settle_seconds": 0.5 +}