From 37cf91cbf15934816fa050ece5e1759b51ef3c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A5=A5114511?= Date: Tue, 23 Jun 2026 00:56:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(AutoCoffeePro):=20=E6=B7=BB=E5=8A=A0=20Nan?= =?UTF-8?q?allyPro=20=E5=92=8C=20LacrimosaPro=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8C=96=E6=B8=B8=E6=88=8F=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 NanallyPro(自动点击锤子)和 LacrimosaPro(检测并服务顾客果汁)两个 CustomAction - 新增 4 个 Pipeline JSON 文件,含 OCR/TemplateMatch 识别节点 - 新增主编排器 AutoCoffeePro CustomAction - 新增 2 个任务配置文件及可选项 - 更新 interface.json 注册新任务 - 更新 5 个语言文件的界面文案 --- agent/custom/action/AutoCoffeePro/__init__.py | 8 + .../action/AutoCoffeePro/auto_coffee_pro.py | 292 ++++++++++++++++++ .../custom/action/AutoCoffeePro/background.py | 143 +++++++++ agent/custom/action/AutoCoffeePro/modes.py | 179 +++++++++++ agent/custom/action/AutoCoffeePro/utils.py | 130 ++++++++ agent/custom/action/__init__.py | 4 + assets/interface.json | 2 + .../base/image/auto_coffee_pro/exit.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/fail.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/finish.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/glasses.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/hammer.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/juice.png | Bin 0 -> 69 bytes .../image/auto_coffee_pro/level_target.png | Bin 0 -> 17571 bytes .../base/image/auto_coffee_pro/retry.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/start.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/success.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/tomato.png | Bin 0 -> 69 bytes .../base/image/auto_coffee_pro/win.png | Bin 0 -> 69 bytes .../pipeline/AutoCoffeePro/AutoCoffeePro.json | 33 ++ .../AutoCoffeeProRecognition.json | 144 +++++++++ .../pipeline/AutoCoffeePro/LacrimosaPro.json | 59 ++++ .../pipeline/AutoCoffeePro/NanallyPro.json | 34 ++ assets/resource/locales/interface/en_us.json | 8 + assets/resource/locales/interface/ja_jp.json | 8 + assets/resource/locales/interface/ko_kr.json | 8 + assets/resource/locales/interface/zh_cn.json | 8 + assets/resource/locales/interface/zh_tw.json | 8 + assets/resource/tasks/LacrimosaPro.json | 58 ++++ assets/resource/tasks/NanallyPro.json | 58 ++++ 30 files changed, 1184 insertions(+) create mode 100644 agent/custom/action/AutoCoffeePro/__init__.py create mode 100644 agent/custom/action/AutoCoffeePro/auto_coffee_pro.py create mode 100644 agent/custom/action/AutoCoffeePro/background.py create mode 100644 agent/custom/action/AutoCoffeePro/modes.py create mode 100644 agent/custom/action/AutoCoffeePro/utils.py create mode 100644 assets/resource/base/image/auto_coffee_pro/exit.png create mode 100644 assets/resource/base/image/auto_coffee_pro/fail.png create mode 100644 assets/resource/base/image/auto_coffee_pro/finish.png create mode 100644 assets/resource/base/image/auto_coffee_pro/glasses.png create mode 100644 assets/resource/base/image/auto_coffee_pro/hammer.png create mode 100644 assets/resource/base/image/auto_coffee_pro/juice.png create mode 100644 assets/resource/base/image/auto_coffee_pro/level_target.png create mode 100644 assets/resource/base/image/auto_coffee_pro/retry.png create mode 100644 assets/resource/base/image/auto_coffee_pro/start.png create mode 100644 assets/resource/base/image/auto_coffee_pro/success.png create mode 100644 assets/resource/base/image/auto_coffee_pro/tomato.png create mode 100644 assets/resource/base/image/auto_coffee_pro/win.png create mode 100644 assets/resource/base/pipeline/AutoCoffeePro/AutoCoffeePro.json create mode 100644 assets/resource/base/pipeline/AutoCoffeePro/AutoCoffeeProRecognition.json create mode 100644 assets/resource/base/pipeline/AutoCoffeePro/LacrimosaPro.json create mode 100644 assets/resource/base/pipeline/AutoCoffeePro/NanallyPro.json create mode 100644 assets/resource/tasks/LacrimosaPro.json create mode 100644 assets/resource/tasks/NanallyPro.json diff --git a/agent/custom/action/AutoCoffeePro/__init__.py b/agent/custom/action/AutoCoffeePro/__init__.py new file mode 100644 index 00000000..49fc8c5c --- /dev/null +++ b/agent/custom/action/AutoCoffeePro/__init__.py @@ -0,0 +1,8 @@ +from .auto_coffee_pro import AutoCoffeePro as AutoCoffeeProAction +from .modes import NanallyProAction, LacrimosaProAction + +__all__ = [ + "AutoCoffeeProAction", + "NanallyProAction", + "LacrimosaProAction", +] diff --git a/agent/custom/action/AutoCoffeePro/auto_coffee_pro.py b/agent/custom/action/AutoCoffeePro/auto_coffee_pro.py new file mode 100644 index 00000000..193af70a --- /dev/null +++ b/agent/custom/action/AutoCoffeePro/auto_coffee_pro.py @@ -0,0 +1,292 @@ +import time +import json + +from maa.agent.agent_server import AgentServer +from maa.custom_action import CustomAction +from maa.context import Context + +from utils.logger import logger +from utils.maafocus import PrintT +from .utils import get_image, click_rect_multiple, press_key_f + + +@AgentServer.custom_action("auto_coffee_pro") +class AutoCoffeePro(CustomAction): + """Orchestrator: full game automation loop. + + Original logic from myshit/game_bot.py GameAutomationBot.run(): + 1. Level selection (find target level, click enter, wait for start) + 2. Run game mode (delegated to sub-actions) + 3. Exit level and handle result (retry on fail, finish on success) + 4. Loop until max_rounds reached or task stopped + + Pipeline usage: + ```jsonc + { + "AutoCoffeeProRun": { + "action": "Custom", + "custom_action": "auto_coffee_pro", + "custom_action_param": { + "mode": "nanally", + "max_rounds": 20, + "level_search_timeout": 60, + "start_button_timeout": 8, + "exit_button_timeout": 8, + "result_timeout": 4 + } + } + } + ``` + """ + + def run( + self, context: Context, argv: CustomAction.RunArg + ) -> CustomAction.RunResult: + controller = context.tasker.controller + + # ── Parse parameters from custom_action_param ── + mode = "nanally" + max_rounds = 20 + level_search_timeout = 60.0 + start_button_timeout = 8.0 + exit_button_timeout = 8.0 + result_timeout = 4.0 + level_node = "AutoCoffeeProFindLevel" + start_node = "AutoCoffeeProCheckStart" + exit_node = "AutoCoffeeProCheckExit" + success_node = "AutoCoffeeProCheckSuccess" + fail_node = "AutoCoffeeProCheckFail" + retry_node = "AutoCoffeeProCheckRetry" + finish_node = "AutoCoffeeProCheckFinish" + + if argv.custom_action_param: + try: + params = json.loads(argv.custom_action_param) + mode = params.get("mode", mode) + max_rounds = params.get("max_rounds", max_rounds) + level_search_timeout = params.get("level_search_timeout", level_search_timeout) + start_button_timeout = params.get("start_button_timeout", start_button_timeout) + exit_button_timeout = params.get("exit_button_timeout", exit_button_timeout) + result_timeout = params.get("result_timeout", result_timeout) + level_node = params.get("level_node", level_node) + start_node = params.get("start_node", start_node) + exit_node = params.get("exit_node", exit_node) + success_node = params.get("success_node", success_node) + fail_node = params.get("fail_node", fail_node) + retry_node = params.get("retry_node", retry_node) + finish_node = params.get("finish_node", finish_node) + except json.JSONDecodeError as e: + logger.error("AutoCoffeePro: failed to parse params: %s", e) + + PrintT(context, "AutoCoffeePro started (mode=%s, max_rounds=%d)", mode, max_rounds) + logger.info("AutoCoffeePro: mode=%s, max_rounds=%d", mode, max_rounds) + + # ── Main round loop ── + for round_index in range(1, max_rounds + 1): + if context.tasker.stopping: + logger.info("AutoCoffeePro: task stopping, exiting at round %d", round_index) + return CustomAction.RunResult(success=True) + + PrintT(context, "AutoCoffeePro: round %d/%d", round_index, max_rounds) + + # ── Step 1: Level Selection ── + if not self._select_level( + context, controller, + level_node, level_search_timeout, + start_node, start_button_timeout, + ): + logger.error("AutoCoffeePro: level selection failed on round %d", round_index) + return CustomAction.RunResult(success=False) + + # ── Step 2: Run Game Mode ── + if not self._run_mode(context, controller, mode): + logger.warning("AutoCoffeePro: mode '%s' failed on round %d", mode, round_index) + # Continue to exit handling anyway + + # ── Step 3: Exit Level and Handle Result ── + self._exit_and_handle_result( + context, controller, + exit_node, exit_button_timeout, + success_node, fail_node, retry_node, finish_node, result_timeout, + ) + + PrintT(context, "AutoCoffeePro: all %d rounds completed", max_rounds) + return CustomAction.RunResult(success=True) + + # ────────────────────────────────────────────── + # Internal helpers + # ────────────────────────────────────────────── + + def _select_level( + self, + context: Context, + controller, + level_node: str, + level_timeout: float, + start_node: str, + start_timeout: float, + ) -> bool: + """Find and click target level, then click start button.""" + logger.info("AutoCoffeePro: searching for target level") + + deadline = time.time() + level_timeout + while time.time() < deadline: + if context.tasker.stopping: + return False + + img = get_image(controller) + level_result = context.run_recognition(level_node, img) + if level_result and level_result.hit: + logger.info("AutoCoffeePro: target level found") + click_rect_multiple( + controller, + [level_result.box.x, level_result.box.y, level_result.box.w, level_result.box.h], + ) + time.sleep(0.5) + break + time.sleep(0.3) + else: + logger.error("AutoCoffeePro: level not found within %.1f seconds", level_timeout) + return False + + # Wait for start button + deadline = time.time() + start_timeout + while time.time() < deadline: + if context.tasker.stopping: + return False + + img = get_image(controller) + start_result = context.run_recognition(start_node, img) + if start_result and start_result.hit: + click_rect_multiple( + controller, + [start_result.box.x, start_result.box.y, start_result.box.w, start_result.box.h], + ) + logger.info("AutoCoffeePro: start button clicked") + time.sleep(1.5) + return True + time.sleep(0.3) + + logger.error("AutoCoffeePro: start button not found") + return False + + def _run_mode( + self, context: Context, controller, mode: str + ) -> bool: + """Run the specified game mode via pipeline sub-actions. + + The mode is executed by running a pipeline action node. + The user must have pipeline nodes defined for each mode. + """ + mode_node_map = { + "nanally": "NanallyProRun", + "lacrimosa": "LacrimosaProRun", + } + + node_name = mode_node_map.get(mode) + if not node_name: + logger.error("AutoCoffeePro: unknown mode '%s'", mode) + return False + + # Run the mode via pipeline action + context.run_action(node_name) + logger.info("AutoCoffeePro: mode '%s' execution completed", mode) + return True + + def _exit_and_handle_result( + self, + context: Context, + controller, + exit_node: str, + exit_timeout: float, + success_node: str, + fail_node: str, + retry_node: str, + finish_node: str, + result_timeout: float, + ) -> None: + """Exit the current level and handle result screen. + + - Detects fail/combo_break → click retry if available + - Otherwise → click finish + """ + # Click exit + deadline = time.time() + exit_timeout + while time.time() < deadline: + if context.tasker.stopping: + return + + img = get_image(controller) + exit_result = context.run_recognition(exit_node, img) + if exit_result and exit_result.hit: + click_rect_multiple( + controller, + [exit_result.box.x, exit_result.box.y, exit_result.box.w, exit_result.box.h], + ) + logger.info("AutoCoffeePro: exit button clicked") + time.sleep(0.8) + break + time.sleep(0.3) + else: + logger.warning("AutoCoffeePro: exit button not found, continuing") + + # Detect result state + deadline = time.time() + result_timeout + state = None + while time.time() < deadline: + if context.tasker.stopping: + return + + img = get_image(controller) + fail_result = context.run_recognition(fail_node, img) + if fail_result and fail_result.hit: + state = "fail" + logger.warning("AutoCoffeePro: detected fail result") + break + + success_result = context.run_recognition(success_node, img) + if success_result and success_result.hit: + state = "success" + logger.info("AutoCoffeePro: detected success result") + break + + time.sleep(0.25) + + # Handle retry on fail + if state == "fail": + deadline = time.time() + 4.0 # retry_button_timeout + while time.time() < deadline: + if context.tasker.stopping: + return + + img = get_image(controller) + retry_result = context.run_recognition(retry_node, img) + if retry_result and retry_result.hit: + click_rect_multiple( + controller, + [retry_result.box.x, retry_result.box.y, retry_result.box.w, retry_result.box.h], + ) + logger.info("AutoCoffeePro: retry clicked after fail") + time.sleep(1.5) + return + time.sleep(0.3) + + # Click finish (success or fallback) + deadline = time.time() + 8.0 # finish_button_timeout + while time.time() < deadline: + if context.tasker.stopping: + return + + img = get_image(controller) + finish_result = context.run_recognition(finish_node, img) + if finish_result and finish_result.hit: + click_rect_multiple( + controller, + [finish_result.box.x, finish_result.box.y, finish_result.box.w, finish_result.box.h], + ) + logger.info("AutoCoffeePro: finish clicked") + time.sleep(1.2) + return + time.sleep(0.3) + + logger.warning("AutoCoffeePro: finish button not found") diff --git a/agent/custom/action/AutoCoffeePro/background.py b/agent/custom/action/AutoCoffeePro/background.py new file mode 100644 index 00000000..ff9e78a7 --- /dev/null +++ b/agent/custom/action/AutoCoffeePro/background.py @@ -0,0 +1,143 @@ +import cv2 +import numpy as np +import time + +from maa.agent.agent_server import AgentServer +from maa.custom_recognition import CustomRecognition +from maa.context import Context + +from utils.logger import logger +from .utils import get_image + + +_background_gray: np.ndarray = None +_background_region_name: str = "" +_background_captured: bool = False + + +@AgentServer.custom_recognition("BackgroundDiffPro") +class BackgroundDiffPro(CustomRecognition): + """Detect foreground objects (e.g. customers) via background subtraction. + + Captures a clean background image on first call, then performs + frame differencing on subsequent calls to detect changes. + + Pipeline usage: + ```jsonc + { + "DetectCustomers": { + "recognition": { + "type": "Custom", + "param": { + "custom_recognition": "BackgroundDiffPro", + "custom_recognition_param": { + "region_name": "gameplay_area", + "diff_threshold": 28, + "min_area": 1800, + "max_area": 80000 + } + } + }, + "next": ["ServeCustomer"] + } + } + ``` + """ + + def analyze( + self, context: Context, argv: CustomRecognition.AnalyzeArg + ) -> CustomRecognition.AnalyzeResult: + global _background_gray, _background_region_name, _background_captured + + controller = context.tasker.controller + img = get_image(controller) + + # Parse parameters + region_name = "gameplay_area" + diff_threshold = 28 + min_area = 1800 + max_area = 80000 + morph_kernel = 5 + capture_delay = 1.2 + + if argv.custom_recognition_param: + params = argv.custom_recognition_param + region_name = params.get("region_name", region_name) + diff_threshold = params.get("diff_threshold", diff_threshold) + min_area = params.get("min_area", min_area) + max_area = params.get("max_area", max_area) + morph_kernel = params.get("morph_kernel", morph_kernel) + capture_delay = params.get("capture_delay", capture_delay) + + # Parse region from param or use full image + roi = argv.custom_recognition_param.get("roi", [0, 0, img.shape[1], img.shape[0]]) if argv.custom_recognition_param else [0, 0, img.shape[1], img.shape[0]] + x, y, w, h = roi + frame_roi = img[y : y + h, x : x + w] + frame_gray = cv2.cvtColor(frame_roi, cv2.COLOR_BGR2GRAY) + + # Capture clean background on first call + if not _background_captured or _background_region_name != region_name: + logger.info("BackgroundDiffPro: capturing clean background for '%s'", region_name) + time.sleep(capture_delay) + img = get_image(controller) + frame_roi = img[y : y + h, x : x + w] + _background_gray = cv2.cvtColor(frame_roi, cv2.COLOR_BGR2GRAY) + _background_region_name = region_name + _background_captured = True + logger.info("BackgroundDiffPro: background captured (shape=%s)", _background_gray.shape) + + if _background_gray.shape != frame_gray.shape: + logger.warning( + "BackgroundDiffPro: background shape %s != frame shape %s, recapturing", + _background_gray.shape, frame_gray.shape, + ) + _background_gray = frame_gray.copy() + + # Compute absolute difference + diff = cv2.absdiff(_background_gray, frame_gray) + _, binary = cv2.threshold(diff, diff_threshold, 255, cv2.THRESH_BINARY) + + kernel = np.ones((morph_kernel, morph_kernel), np.uint8) + binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) + binary = cv2.dilate(binary, kernel, iterations=2) + + contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + boxes = [] + for contour in contours: + area = cv2.contourArea(contour) + if area < min_area or area > max_area: + continue + bx, by, bw, bh = cv2.boundingRect(contour) + if bw < 20 or bh < 20: + continue + # Convert back to full-image coordinates + boxes.append([x + bx, y + by, bw, bh]) + + if boxes: + logger.debug("BackgroundDiffPro: detected %d foreground boxes", len(boxes)) + return CustomRecognition.AnalyzeResult( + box=(boxes[0][0], boxes[0][1], boxes[0][2], boxes[0][3]), + detail={"boxes": boxes, "count": len(boxes)}, + ) + + return None + + +@AgentServer.custom_recognition("BackgroundDiffProReset") +class BackgroundDiffProReset(CustomRecognition): + """Reset the captured background so it gets re-captured on next call. + + Pipeline usage: call this before entering a new level/scene + to ensure the background reflects the new environment. + """ + + def analyze( + self, context: Context, argv: CustomRecognition.AnalyzeArg + ) -> CustomRecognition.AnalyzeResult: + global _background_captured, _background_gray, _background_region_name + _background_captured = False + _background_gray = None + _background_region_name = "" + logger.info("BackgroundDiffProReset: background reset") + return CustomRecognition.AnalyzeResult(box=(0, 0, 0, 0)) diff --git a/agent/custom/action/AutoCoffeePro/modes.py b/agent/custom/action/AutoCoffeePro/modes.py new file mode 100644 index 00000000..d26cde44 --- /dev/null +++ b/agent/custom/action/AutoCoffeePro/modes.py @@ -0,0 +1,179 @@ +import time +import json + +from maa.agent.agent_server import AgentServer +from maa.custom_action import CustomAction +from maa.context import Context + +from utils.logger import logger +from utils.maafocus import PrintT +from .utils import get_image, click_rect_multiple, press_key_f + + +@AgentServer.custom_action("nanally_pro") +class NanallyProAction(CustomAction): + """Mode 1 (Nanally / BaiCang): repeatedly click hammer until win template appears. + + Pipeline usage: + ```jsonc + { + "NanallyProRun": { + "action": "Custom", + "custom_action": "nanally_pro", + "custom_action_param": { + "max_loop_seconds": 300, + "loop_interval": 0.2 + } + } + } + ``` + """ + + def run( + self, context: Context, argv: CustomAction.RunArg + ) -> CustomAction.RunResult: + controller = context.tasker.controller + + max_loop_seconds = 300.0 + loop_interval = 0.2 + hammer_recognition_node = "NanallyProCheckHammer" + + if argv.custom_action_param: + params = json.loads(argv.custom_action_param) + max_loop_seconds = params.get("max_loop_seconds", max_loop_seconds) + loop_interval = params.get("loop_interval", loop_interval) + hammer_recognition_node = params.get("hammer_recognition_node", hammer_recognition_node) + + start_time = time.time() + PrintT(context, "NanallyPro: started hammer clicking mode") + + while True: + if context.tasker.stopping: + return CustomAction.RunResult(success=False) + + # Check timeout + if time.time() - start_time > max_loop_seconds: + logger.warning("NanallyPro timed out after %.1f seconds", max_loop_seconds) + return CustomAction.RunResult(success=False) + + # Click hammer + img = get_image(controller) + hammer_result = context.run_recognition(hammer_recognition_node, img) + if hammer_result and hammer_result.hit: + click_rect_multiple( + controller, + [hammer_result.box.x, hammer_result.box.y, hammer_result.box.w, hammer_result.box.h], + ) + else: + logger.debug("NanallyPro: hammer not found, waiting") + time.sleep(loop_interval) + + +@AgentServer.custom_action("lacrimosa_pro") +class LacrimosaProAction(CustomAction): + """Mode 2 (Lacrimosa): detect juice dish above customers and serve them. + + Detection flow: + 1. Use BackgroundDiffPro (CustomRecognition) to find customer positions + 2. For each customer, check if a juice icon appears above them + 3. If found, click glasses -> click tomato to serve + + Pipeline usage: + ```jsonc + { + "LacrimosaProRun": { + "action": "Custom", + "custom_action": "lacrimosa_pro", + "custom_action_param": { + "max_services": 10, + "max_loop_seconds": 300, + "idle_sleep": 0.15 + } + } + } + ``` + """ + + def run( + self, context: Context, argv: CustomAction.RunArg + ) -> CustomAction.RunResult: + controller = context.tasker.controller + + max_services = 10 + max_loop_seconds = 300.0 + idle_sleep = 0.15 + tool_interval = 0.12 + after_service_delay = 0.18 + post_service_cooldown = 0.3 + juice_recognition_node = "LacrimosaProCheckJuice" + glasses_recognition_node = "LacrimosaProCheckGlasses" + tomato_recognition_node = "LacrimosaProCheckTomato" + + if argv.custom_action_param: + params = json.loads(argv.custom_action_param) + max_services = params.get("max_services", max_services) + max_loop_seconds = params.get("max_loop_seconds", max_loop_seconds) + idle_sleep = params.get("idle_sleep", idle_sleep) + tool_interval = params.get("tool_interval", tool_interval) + after_service_delay = params.get("after_service_delay", after_service_delay) + post_service_cooldown = params.get("post_service_cooldown", post_service_cooldown) + juice_recognition_node = params.get("juice_recognition_node", juice_recognition_node) + glasses_recognition_node = params.get("glasses_recognition_node", glasses_recognition_node) + tomato_recognition_node = params.get("tomato_recognition_node", tomato_recognition_node) + + service_count = 0 + start_time = time.time() + PrintT(context, "LacrimosaPro: started juice detection mode") + + while service_count < max_services: + if context.tasker.stopping: + return CustomAction.RunResult(success=False) + + if time.time() - start_time > max_loop_seconds: + logger.warning("LacrimosaPro timed out after %.1f seconds", max_loop_seconds) + return CustomAction.RunResult(success=False) + + # Step 1: Detect juice demand (customer head icon) + img = get_image(controller) + juice_result = context.run_recognition(juice_recognition_node, img) + if not (juice_result and juice_result.hit): + time.sleep(idle_sleep) + continue + + logger.info("LacrimosaPro: juice demand detected, score=%.3f", getattr(juice_result, "score", 0)) + + # Step 2: Click glasses tool + img = get_image(controller) + glasses_result = context.run_recognition(glasses_recognition_node, img) + if not (glasses_result and glasses_result.hit): + logger.warning("LacrimosaPro: glasses not found, skipping service") + time.sleep(idle_sleep) + continue + + click_rect_multiple( + controller, + [glasses_result.box.x, glasses_result.box.y, glasses_result.box.w, glasses_result.box.h], + ) + time.sleep(tool_interval) + + # Step 3: Click tomato tool + img = get_image(controller) + tomato_result = context.run_recognition(tomato_recognition_node, img) + if not (tomato_result and tomato_result.hit): + logger.warning("LacrimosaPro: tomato not found, skipping service") + continue + + click_rect_multiple( + controller, + [tomato_result.box.x, tomato_result.box.y, tomato_result.box.w, tomato_result.box.h], + ) + time.sleep(after_service_delay) + + # Cooldown to avoid re-triggering on the same customer + time.sleep(post_service_cooldown) + + service_count += 1 + logger.info("LacrimosaPro: completed service %d/%d", service_count, max_services) + + PrintT(context, "LacrimosaPro: all services completed") + return CustomAction.RunResult(success=True) diff --git a/agent/custom/action/AutoCoffeePro/utils.py b/agent/custom/action/AutoCoffeePro/utils.py new file mode 100644 index 00000000..fbbf7a3f --- /dev/null +++ b/agent/custom/action/AutoCoffeePro/utils.py @@ -0,0 +1,130 @@ +import time +import cv2 +import numpy as np + +from maa.context import Context + +from utils.logger import logger +from utils.maafocus import PrintT + + +def get_image(controller): + """Take a screenshot using the Maa controller API.""" + job = controller.post_screencap() + job.wait() + return controller.cached_image + + +def click_rect(controller, rect, delay=0.05): + """Click the center of a rectangle [x, y, w, h].""" + x, y, w, h = rect + cx = x + w // 2 + cy = y + h // 2 + controller.post_touch_down(cx, cy).wait() + time.sleep(delay) + controller.post_touch_up().wait() + + +def click_rect_multiple(controller, rect, repeat=3): + """Click multiple times for reliability.""" + x, y, w, h = rect + cx = x + w // 2 + cy = y + h // 2 + for _ in range(repeat): + controller.post_touch_down(cx, cy).wait() + time.sleep(0.05) + controller.post_touch_up().wait() + + +def press_key_f(controller): + """Press and release the F key.""" + KEY_F = 70 + controller.post_key_down(KEY_F).wait() + time.sleep(0.1) + controller.post_key_up(KEY_F).wait() + + +def press_key_esc(controller): + """Press and release the Escape key.""" + KEY_ESC = 27 + controller.post_key_down(KEY_ESC).wait() + time.sleep(0.1) + controller.post_key_up(KEY_ESC).wait() + + +def match_template_in_region( + img, region, template, min_similarity=0.8, green_mask=False +): + """OpenCV template matching within a region. + + Args: + img: Full-screen image (numpy BGR array). + region: [x, y, w, h] within the image. + template: Template image (numpy array). + min_similarity: Minimum match score threshold. + green_mask: Whether to use green-channel masking. + + Returns: + (hit, score, x, y) where (x, y) is the top-left in region coordinates. + """ + if img is None or not isinstance(img, np.ndarray): + return False, 0.0, 0, 0 + + x1, y1, w, h = region + x2, y2 = x1 + w, y1 + h + + h, w = img.shape[:2] + x1, y1 = max(0, x1), max(0, y1) + x2, y2 = min(w, x2), min(h, y2) + + if x2 <= x1 or y2 <= y1: + return False, 0.0, 0, 0 + + roi = img[y1:y2, x1:x2] + + if len(roi.shape) == 3 and roi.shape[2] == 4: + roi = cv2.cvtColor(roi, cv2.COLOR_BGRA2BGR) + + if green_mask: + lower_green = np.array([0, 255, 0], dtype=np.uint8) + upper_green = np.array([0, 255, 0], dtype=np.uint8) + mask = cv2.bitwise_not(cv2.inRange(template, lower_green, upper_green)) + res = cv2.matchTemplate(roi, template, cv2.TM_CCOEFF_NORMED, mask=mask) + else: + res = cv2.matchTemplate(roi, template, cv2.TM_CCOEFF_NORMED) + + res = np.nan_to_num(res, nan=-1.0, posinf=-1.0, neginf=-1.0) + np.clip(res, 0.0, 1.0, out=res) + _, max_val, _, max_loc = cv2.minMaxLoc(res) + + if max_val >= min_similarity: + return True, max_val, x1 + max_loc[0], y1 + max_loc[1] + return False, max_val, 0, 0 + + +def wait_for_condition( + condition_fn, + timeout=30.0, + interval=0.3, + stopping=None, +): + """Poll a condition function until it returns True or timeout. + + Args: + condition_fn: Callable that returns a truthy value. + timeout: Maximum seconds to wait. + interval: Poll interval in seconds. + stopping: Optional callable that returns True when task should stop. + + Returns: + The truthy value from condition_fn, or None on timeout/stop. + """ + deadline = time.time() + timeout + while time.time() < deadline: + if stopping is not None and stopping(): + return None + result = condition_fn() + if result: + return result + time.sleep(interval) + return None diff --git a/agent/custom/action/__init__.py b/agent/custom/action/__init__.py index 6693d0d8..fb2887b5 100644 --- a/agent/custom/action/__init__.py +++ b/agent/custom/action/__init__.py @@ -29,6 +29,7 @@ from .SyncCharacterAbilityCityAbility import * from .DatasetCollection.autonomous_driving_dataset_recorder import * from .BagelSpam import * +from .AutoCoffeePro import * __all__ = [ "AutoMakeCoffee", @@ -68,4 +69,7 @@ "BagelSpamPickIndex", "BagelSpamOutputText", "BagelSpamLLMGenerate", + "AutoCoffeeProAction", + "NanallyProAction", + "LacrimosaProAction", ] diff --git a/assets/interface.json b/assets/interface.json index 66969688..a9c60d80 100644 --- a/assets/interface.json +++ b/assets/interface.json @@ -116,6 +116,8 @@ "resource/tasks/PinkPawHeist.json", "resource/tasks/MakeCoffee.json", "resource/tasks/MakeCoffeeLite.json", + "resource/tasks/NanallyPro.json", + "resource/tasks/LacrimosaPro.json", "resource/tasks/Rhythm.json", "resource/tasks/Tetris.json", "resource/tasks/BagelSpam.json", diff --git a/assets/resource/base/image/auto_coffee_pro/exit.png b/assets/resource/base/image/auto_coffee_pro/exit.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/assets/resource/base/image/auto_coffee_pro/fail.png b/assets/resource/base/image/auto_coffee_pro/fail.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/assets/resource/base/image/auto_coffee_pro/finish.png b/assets/resource/base/image/auto_coffee_pro/finish.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/assets/resource/base/image/auto_coffee_pro/glasses.png b/assets/resource/base/image/auto_coffee_pro/glasses.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/assets/resource/base/image/auto_coffee_pro/hammer.png b/assets/resource/base/image/auto_coffee_pro/hammer.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/assets/resource/base/image/auto_coffee_pro/juice.png b/assets/resource/base/image/auto_coffee_pro/juice.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/assets/resource/base/image/auto_coffee_pro/level_target.png b/assets/resource/base/image/auto_coffee_pro/level_target.png new file mode 100644 index 0000000000000000000000000000000000000000..576c1ed68707452df35df55604ed62e6fe29ee59 GIT binary patch literal 17571 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 oz;{du05gfe_0