diff --git a/assets/banner_victory.png b/assets/banner_victory.png index 03b58e7..b3e6dc1 100644 Binary files a/assets/banner_victory.png and b/assets/banner_victory.png differ diff --git a/assets/btn_collect_victory.png b/assets/btn_collect_victory.png index 82d7e2f..7b72aa4 100644 Binary files a/assets/btn_collect_victory.png and b/assets/btn_collect_victory.png differ diff --git a/assets/text_victory.png b/assets/text_victory.png index e6e9725..e6f8101 100644 Binary files a/assets/text_victory.png and b/assets/text_victory.png differ diff --git a/bot_core.py b/bot_core.py index 60b71a8..0d1aaec 100644 --- a/bot_core.py +++ b/bot_core.py @@ -1,6 +1,7 @@ """ Core FSM Bot engine for DirtyLeague. Manages screen recognition, game state transitions, and automated inputs. +Supports resolution-independent relative coordinates and adaptive template scaling. """ from enum import Enum @@ -10,7 +11,6 @@ import time from typing import Dict, Optional, Tuple import cv2 import numpy as np -import pydirectinput from window_utils import WindowManager from ocr_utils import ocr_reader @@ -27,6 +27,25 @@ class GameState(Enum): class DirtyLeagueBot: + # Reference design resolution from which templates and coordinate ratios were calibrated + BASE_DESIGN_WIDTH = 3840 + BASE_DESIGN_HEIGHT = 2050 + + # Normalized relative ROIs (rx1, ry1, rx2, ry2) spanning 0.0 to 1.0 + REL_ROI_EXIT = (0.4167, 0.9268, 0.5000, 0.9854) + REL_ROI_LEAVE = (0.4948, 0.5610, 0.6250, 0.6829) + REL_ROI_FIGHT = (0.7812, 0.3659, 0.9245, 0.4878) + REL_ROI_DEFEAT_OK = (0.2344, 0.7220, 0.3776, 0.8293) + REL_ROI_DEFEAT_TEXT = (0.1302, 0.6829, 0.3125, 0.8293) + REL_ROI_VICTORY_COLLECT = (0.1953, 0.7561, 0.3125, 0.8537) + REL_ROI_VICTORY_BANNER = (0.1042, 0.1463, 0.4167, 0.3415) + REL_ROI_NO_SLOTS_REMOVE = (0.3516, 0.8195, 0.5078, 0.9024) + REL_ROI_NO_SLOTS_OPEN = (0.4818, 0.8195, 0.6120, 0.9024) + REL_ROI_CHEST_TURN_CARDS = (0.4036, 0.6585, 0.5859, 0.7561) + REL_ROI_CHEST_COLLECT = (0.4036, 0.6585, 0.5859, 0.7561) + REL_ROI_OFFER_CLOSE = (0.8854, 0.0, 1.0, 0.1463) + REL_CROWN_CHEST_POS = (0.2279, 0.6146) + def __init__(self, config: dict): self.config = config self.bot_conf = config.get("bot", {}) @@ -56,11 +75,20 @@ class DirtyLeagueBot: window_title=self.target_conf.get("title", "DirtyLeague"), process_name=self.target_conf.get("process_name", "DirtyLeague.exe") ) - self.templates: Dict[str, np.ndarray] = {} + + # Template storage with dynamic scaling cache + self.base_templates: Dict[str, np.ndarray] = {} + self.scaled_templates: Dict[str, np.ndarray] = {} + self._current_scale: float = 1.0 self._load_templates() + @property + def templates(self) -> Dict[str, np.ndarray]: + """Provides access to templates at the current resolution scale.""" + return self.scaled_templates if self.scaled_templates else self.base_templates + def _load_templates(self): - """Preloads all PNG template images from the assets directory.""" + """Preloads all base PNG template images from the assets directory.""" if not os.path.exists(self.assets_dir): return for file_name in os.listdir(self.assets_dir): @@ -69,27 +97,52 @@ class DirtyLeagueBot: path = os.path.join(self.assets_dir, file_name) img = cv2.imread(path) if img is not None: - self.templates[name_without_ext] = img - logging.info(f"Loaded {len(self.templates)} template(s) from '{self.assets_dir}': {list(self.templates.keys())}") + self.base_templates[name_without_ext] = img + self.scaled_templates[name_without_ext] = img + logging.info(f"Loaded {len(self.base_templates)} base template(s) from '{self.assets_dir}'") - def find_template(self, frame: np.ndarray, template_name: str, threshold: Optional[float] = None) -> Optional[Tuple[int, int]]: + def get_scaled_template(self, template_name: str, scale: float) -> Optional[np.ndarray]: """ - Searches for template_name in frame. - Returns client center coordinates (center_x, center_y) if matched above threshold, else None. + Retrieves template scaled for the given resolution scale factor (fw / BASE_DESIGN_WIDTH). + Caches scaled results so resizing only happens on window resolution changes. """ - if template_name not in self.templates: - # Try reloading in case new template was added + if template_name not in self.base_templates: path = os.path.join(self.assets_dir, f"{template_name}.png") if os.path.exists(path): img = cv2.imread(path) if img is not None: - self.templates[template_name] = img - if template_name not in self.templates: + self.base_templates[template_name] = img + if template_name not in self.base_templates: return None - tpl = self.templates[template_name] - th, tw = tpl.shape[:2] + # If resolution scale changed, rebuild scaled template cache + if abs(scale - self._current_scale) > 0.02: + self._current_scale = scale + self.scaled_templates.clear() + interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC + for name, base_img in self.base_templates.items(): + if abs(scale - 1.0) <= 0.02: + self.scaled_templates[name] = base_img + else: + self.scaled_templates[name] = cv2.resize( + base_img, (0, 0), fx=scale, fy=scale, interpolation=interp + ) + + return self.scaled_templates.get(template_name) + + def find_template(self, frame: np.ndarray, template_name: str, threshold: Optional[float] = None) -> Optional[Tuple[int, int]]: + """ + Full-frame search for template_name in frame. + Automatically scales template to match the current frame scale. + Returns client center coordinates (center_x, center_y) if matched, else None. + """ fh, fw = frame.shape[:2] + scale = fw / float(self.BASE_DESIGN_WIDTH) + tpl = self.get_scaled_template(template_name, scale) + if tpl is None: + return None + + th, tw = tpl.shape[:2] if th > fh or tw > fw: return None @@ -105,28 +158,32 @@ class DirtyLeagueBot: return None - def find_template_in_roi( + def find_template_in_rel_roi( self, frame: np.ndarray, template_name: str, - roi: Tuple[int, int, int, int], + rel_roi: Tuple[float, float, float, float], threshold: Optional[float] = None ) -> Optional[Tuple[int, int]]: """ - Fast template search restricted to ROI: (x1, y1, x2, y2). + Fast template search restricted to normalized relative ROI: (rx1, ry1, rx2, ry2). Returns client center coordinates (cx, cy) if matched, else None. - Runs in ~2-5ms instead of ~420ms for full-frame search. + Automatically scales template to match current frame resolution. Runs in ~2-5ms! """ - if template_name not in self.templates: - return None - tpl = self.templates[template_name] - th, tw = tpl.shape[:2] fh, fw = frame.shape[:2] + rx1, ry1, rx2, ry2 = rel_roi + x1 = max(0, min(fw, int(rx1 * fw))) + y1 = max(0, min(fh, int(ry1 * fh))) + x2 = max(0, min(fw, int(rx2 * fw))) + y2 = max(0, min(fh, int(ry2 * fh))) - x1, y1, x2, y2 = roi - x1, y1 = max(0, x1), max(0, y1) - x2, y2 = min(fw, x2), min(fh, y2) - if x2 - x1 < tw or y2 - y1 < th: + scale = fw / float(self.BASE_DESIGN_WIDTH) + tpl = self.get_scaled_template(template_name, scale) + if tpl is None: + return None + + th, tw = tpl.shape[:2] + if (x2 - x1) < tw or (y2 - y1) < th: return None crop = frame[y1:y2, x1:x2] @@ -140,6 +197,20 @@ class DirtyLeagueBot: return cx, cy return None + def find_template_in_roi( + self, + frame: np.ndarray, + template_name: str, + roi: Tuple[int, int, int, int], + threshold: Optional[float] = None + ) -> Optional[Tuple[int, int]]: + """Backward-compatible pixel ROI search: converts pixel ROI to relative ROI.""" + rx1 = roi[0] / float(self.BASE_DESIGN_WIDTH) + ry1 = roi[1] / float(self.BASE_DESIGN_HEIGHT) + rx2 = roi[2] / float(self.BASE_DESIGN_WIDTH) + ry2 = roi[3] / float(self.BASE_DESIGN_HEIGHT) + return self.find_template_in_rel_roi(frame, template_name, (rx1, ry1, rx2, ry2), threshold) + def click_client_pos(self, x: int, y: int): """Translates client coordinates to screen and performs a click via Win32 API.""" screen_x, screen_y = self.wm.client_to_screen(x, y) @@ -175,29 +246,29 @@ class DirtyLeagueBot: return False def detect_state(self, frame: np.ndarray) -> GameState: - """Determines the current game screen based on visible templates (accelerated with ROIs).""" - # 1. Ultra-fast ROI checks (~3-5ms each instead of ~420ms full-frame) - if self.find_template_in_roi(frame, "btn_remove_chest", (1350, 1680, 1950, 1850)) or \ - self.find_template_in_roi(frame, "btn_open_chest", (1850, 1680, 2350, 1850)): + """Determines current game screen based on visible templates (accelerated with relative ROIs).""" + # 1. Ultra-fast relative ROI checks (~3-5ms each on any resolution) + if self.find_template_in_rel_roi(frame, "btn_remove_chest", self.REL_ROI_NO_SLOTS_REMOVE) or \ + self.find_template_in_rel_roi(frame, "btn_open_chest", self.REL_ROI_NO_SLOTS_OPEN): return GameState.NO_FREE_SLOTS_SCREEN - if self.find_template_in_roi(frame, "btn_ok", (900, 1480, 1450, 1700)) or \ - self.find_template_in_roi(frame, "text_defeat", (500, 1400, 1200, 1700), threshold=0.65): + if self.find_template_in_rel_roi(frame, "btn_ok", self.REL_ROI_DEFEAT_OK) or \ + self.find_template_in_rel_roi(frame, "text_defeat", self.REL_ROI_DEFEAT_TEXT, threshold=0.65): return GameState.DEFEAT_SCREEN - if self.find_template_in_roi(frame, "btn_exit", (1600, 1900, 1920, 2020)) or \ - self.find_template_in_roi(frame, "btn_leave", (1900, 1150, 2400, 1400)): + if self.find_template_in_rel_roi(frame, "btn_exit", self.REL_ROI_EXIT) or \ + self.find_template_in_rel_roi(frame, "btn_leave", self.REL_ROI_LEAVE): return GameState.IN_GAME - if self.find_template_in_roi(frame, "btn_fight", (3000, 750, 3550, 1000)): + if self.find_template_in_rel_roi(frame, "btn_fight", self.REL_ROI_FIGHT): return GameState.TOWER_LOBBY - if self.find_template_in_roi(frame, "btn_turn_all_cards", (1550, 1350, 2250, 1550)) or \ - self.find_template_in_roi(frame, "btn_collect", (1550, 1350, 2250, 1550), threshold=0.65): + if self.find_template_in_rel_roi(frame, "btn_turn_all_cards", self.REL_ROI_CHEST_TURN_CARDS) or \ + self.find_template_in_rel_roi(frame, "btn_collect", self.REL_ROI_CHEST_COLLECT, threshold=0.65): return GameState.CHEST_OPEN_SCREEN - if self.find_template_in_roi(frame, "btn_collect_victory", (750, 1550, 1200, 1750)) or \ - self.find_template_in_roi(frame, "banner_victory", (400, 300, 1600, 700), threshold=0.65): + if self.find_template_in_rel_roi(frame, "btn_collect_victory", self.REL_ROI_VICTORY_COLLECT) or \ + self.find_template_in_rel_roi(frame, "banner_victory", self.REL_ROI_VICTORY_BANNER, threshold=0.65): return GameState.VICTORY_SCREEN # 2. Full-frame fallback checks if ROI didn't trigger: @@ -220,95 +291,84 @@ class DirtyLeagueBot: def scan_bottom_chests(self, frame: np.ndarray) -> dict: """ - Scans chest slots 1..4 in the bottom bar of TOWER_LOBBY. - Grid calibrated: slot 1 starts at x=525, slot pitch = 213px. - Returns dict of slot_id -> {"status": "OPEN"|"TIMER"|"VACANT", "click_pos": (cx, cy)} + Scans chest slots 1..4 in the bottom bar of TOWER_LOBBY using relative coordinates. + Supports any resolution dynamically. """ - results = {} - slot_pitch = 213 - base_x = 525 - y = 1850 - w = 210 - h = 70 + fh, fw = frame.shape[:2] + rel_base_x = 0.1367 + rel_pitch_x = 0.0555 + rel_y = 0.9024 + rel_w = 0.0547 + rel_h = 0.0341 + scale = fw / float(self.BASE_DESIGN_WIDTH) + tpl_open = self.get_scaled_template("status_open", scale) + tpl_vacant = self.get_scaled_template("status_vacant", scale) + + results = {} for i in range(4): slot_id = i + 1 - sx = base_x + i * slot_pitch - status_crop = frame[y:y + h, sx:sx + w] + sx = int((rel_base_x + i * rel_pitch_x) * fw) + sy = int(rel_y * fh) + sw = int(rel_w * fw) + sh = int(rel_h * fh) + status_crop = frame[sy:sy + sh, sx:sx + sw] - is_open = False - if "status_open" in self.templates: - res_o = cv2.matchTemplate(status_crop, self.templates["status_open"], cv2.TM_CCOEFF_NORMED) - _, max_o, _, _ = cv2.minMaxLoc(res_o) - if max_o >= 0.8: - is_open = True + status = "TIMER" + if tpl_open is not None and status_crop.shape[0] >= tpl_open.shape[0] and status_crop.shape[1] >= tpl_open.shape[1]: + res_open = cv2.matchTemplate(status_crop, tpl_open, cv2.TM_CCOEFF_NORMED) + _, max_v_open, _, _ = cv2.minMaxLoc(res_open) + if max_v_open >= 0.75: + status = "OPEN" - is_vacant = False - if "status_vacant" in self.templates: - res_v = cv2.matchTemplate(status_crop, self.templates["status_vacant"], cv2.TM_CCOEFF_NORMED) - _, max_v, _, _ = cv2.minMaxLoc(res_v) - if max_v >= 0.8: - is_vacant = True - - if is_open: - status = "OPEN" - elif is_vacant: - status = "VACANT" - else: - status = "TIMER" + if status != "OPEN" and tpl_vacant is not None and status_crop.shape[0] >= tpl_vacant.shape[0] and status_crop.shape[1] >= tpl_vacant.shape[1]: + res_vacant = cv2.matchTemplate(status_crop, tpl_vacant, cv2.TM_CCOEFF_NORMED) + _, max_v_vacant, _, _ = cv2.minMaxLoc(res_vacant) + if max_v_vacant >= 0.75: + status = "VACANT" + click_cx = sx + sw // 2 + click_cy = sy - int(0.035 * fh) results[slot_id] = { "status": status, - "click_pos": (sx + w // 2, y - 70) + "click_pos": (click_cx, click_cy) } - logging.debug(f"[CHESTS] Slot #{slot_id}: {status}") return results def check_and_dismiss_offer_popup(self, frame: np.ndarray) -> bool: """ - Detects and dismisses promotional or purchase offer popups ('X' close button) - that may appear randomly or after claiming chest rewards/returning to lobby. - Returns True if a popup was detected and dismissed, False otherwise. + Detects and dismisses promotional or purchase offer popups ('X' close button). + Uses relative ROI for the top-right corner across all resolutions. """ - # Search for close button in top-right quadrant (ROI: y=0..300, x=3400..3840) - fh, fw = frame.shape[:2] - roi_y1, roi_y2 = 0, min(300, fh) - roi_x1, roi_x2 = max(0, fw - 500), fw - roi = frame[roi_y1:roi_y2, roi_x1:roi_x2] - for tpl_name in ["btn_close", "btn_close_offer"]: - if tpl_name not in self.templates: - continue - tpl = self.templates[tpl_name] - th, tw = tpl.shape[:2] - if th > roi.shape[0] or tw > roi.shape[1]: - continue - res = cv2.matchTemplate(roi, tpl, cv2.TM_CCOEFF_NORMED) - _, max_val, _, max_loc = cv2.minMaxLoc(res) - if max_val >= 0.65: - cx = roi_x1 + max_loc[0] + tw // 2 - cy = roi_y1 + max_loc[1] + th // 2 - logging.info(f"[OFFER POPUP] Detected close button '{tpl_name}' (conf: {max_val:.2f}) at client ({cx}, {cy}). Dismissing...") - self.click_client_pos(cx, cy) + pos = self.find_template_in_rel_roi(frame, tpl_name, self.REL_ROI_OFFER_CLOSE, threshold=0.65) + if pos: + logging.info(f"[OFFER POPUP] Detected close button '{tpl_name}' at client {pos}. Dismissing...") + self.click_client_pos(pos[0], pos[1]) time.sleep(1.0) return True - return False def handle_chest_open_screen(self, frame: np.ndarray): logging.info("State: CHEST_OPEN_SCREEN.") # Step 1: Check if "Turn all cards over" is visible - if self.click_template(frame, "btn_turn_all_cards"): + turn_pos = self.find_template_in_rel_roi(frame, "btn_turn_all_cards", self.REL_ROI_CHEST_TURN_CARDS) or \ + self.find_template(frame, "btn_turn_all_cards") + if turn_pos: + self.click_client_pos(turn_pos[0], turn_pos[1]) logging.info("Clicked 'Turn all cards over'. Waiting for card flip animation...") time.sleep(2.0) return # Step 2: Check if "Collect" is visible - if self.click_template(frame, "btn_collect"): + collect_pos = self.find_template_in_rel_roi(frame, "btn_collect", self.REL_ROI_CHEST_COLLECT) or \ + self.find_template(frame, "btn_collect") + if collect_pos: + self.click_client_pos(collect_pos[0], collect_pos[1]) logging.info("Clicked 'Collect'. Rewards claimed, returning to lobby...") time.sleep(1.5) - # Step 3: Check for promotional offer popup stub + # Check for promotional offer popup stub after_frame = self.wm.capture_frame(client_only=True) self.check_and_dismiss_offer_popup(after_frame) @@ -336,52 +396,15 @@ class DirtyLeagueBot: f"ACTIVATING NORMAL PLAY MODE! (Playing to win until >= {self.upper_trophies})" ) - def handle_lobby(self, frame: np.ndarray): - logging.info("State: TOWER_LOBBY.") - # Read trophies counter above FIGHT button - trophies = ocr_reader.read_trophies(frame) - if trophies: - self.current_trophies, self.max_trophies = trophies - logging.info(f"[TROPHIES] Current rating: {self.current_trophies}/{self.max_trophies} (Goal: {self.max_trophies})") - self.update_league_strategy() - - mode_str = "FORCED DERANK (Auto-surrender)" if self.derank_mode else "NORMAL PLAY (Play to win)" - logging.info(f"[STRATEGY] Current Battle Plan: {mode_str}") - - # Check crown chest progress (#/# 👑) - crown_info = ocr_reader.read_crown_chest(frame) - if crown_info: - c_current, c_max = crown_info - logging.info(f"[CROWN CHEST] Progress: {c_current}/{c_max} [CROWN]") - if c_current >= c_max: - logging.info(f"[CROWN CHEST] Goal reached ({c_current}/{c_max})! Clicking crown chest at (875, 1260)...") - self.click_client_pos(875, 1260) - time.sleep(1.5) - return - - # Check bottom chests status - chests = self.scan_bottom_chests(frame) - logging.info(f"[CHESTS] Current slots: { {k: v['status'] for k, v in chests.items()} }") - - # If any chest slot is ready (OPEN), click it to collect rewards - for slot_id, info in sorted(chests.items()): - if info["status"] == "OPEN": - logging.info(f"[CHESTS] Slot #{slot_id} is OPEN! Clicking to claim at {info['click_pos']}...") - self.click_client_pos(*info["click_pos"]) - time.sleep(1.5) - return - def fast_surrender_pipeline(self, max_wait_sec: float = 15.0) -> bool: """ Ultra-fast surrender pipeline for FORCED DERANK. - Polls the Exit button ROI (~20ms per check). The millisecond battle finishes loading, - clicks Exit via fast click, then immediately polls Leave dialog ROI and confirms. + Polls Exit button relative ROI (~20ms per check). The millisecond battle finishes loading, + clicks Exit via fast click, then immediately polls Leave dialog relative ROI and confirms. Surrenders the battle within < 0.15s of battle load, before auto-battle can deal lethal damage. """ logging.info("[FAST DERANK] High-frequency surrender monitor engaged. Waiting for battle screen...") t0 = time.time() - exit_roi = (1600, 1900, 1920, 2020) - leave_roi = (1900, 1150, 2400, 1400) # Step 1: High-frequency polling for Exit button (check every 20ms) exit_clicked = False @@ -393,14 +416,14 @@ class DirtyLeagueBot: time.sleep(0.02) continue - exit_pos = self.find_template_in_roi(frame, "btn_exit", exit_roi, threshold=0.75) + exit_pos = self.find_template_in_rel_roi(frame, "btn_exit", self.REL_ROI_EXIT, threshold=0.75) if exit_pos: logging.info(f"[FAST DERANK] Battle loaded! 'Exit' detected at {exit_pos} in {time.time() - t0:.2f}s. Clicking...") self.click_client_pos_fast(exit_pos[0], exit_pos[1]) exit_clicked = True break - leave_pos = self.find_template_in_roi(frame, "btn_leave", leave_roi, threshold=0.75) + leave_pos = self.find_template_in_rel_roi(frame, "btn_leave", self.REL_ROI_LEAVE, threshold=0.75) if leave_pos: self.click_client_pos_fast(leave_pos[0], leave_pos[1]) logging.info("[FAST DERANK] Clicked existing 'Leave' confirmation.") @@ -422,7 +445,7 @@ class DirtyLeagueBot: time.sleep(0.02) continue - leave_pos = self.find_template_in_roi(frame, "btn_leave", leave_roi, threshold=0.75) + leave_pos = self.find_template_in_rel_roi(frame, "btn_leave", self.REL_ROI_LEAVE, threshold=0.75) if leave_pos: self.click_client_pos_fast(leave_pos[0], leave_pos[1]) logging.info(f"[FAST DERANK] Confirmed 'Leave' in {time.time() - t_leave:.3f}s! Total surrender time: {time.time() - t0:.2f}s.") @@ -437,6 +460,8 @@ class DirtyLeagueBot: def handle_lobby(self, frame: np.ndarray): logging.info("State: TOWER_LOBBY.") self._result_recorded_for_match = False + fh, fw = frame.shape[:2] + # Read trophies counter above FIGHT button trophies = ocr_reader.read_trophies(frame) if trophies: @@ -453,8 +478,10 @@ class DirtyLeagueBot: c_current, c_max = crown_info logging.info(f"[CROWN CHEST] Progress: {c_current}/{c_max} [CROWN]") if c_current >= c_max: - logging.info(f"[CROWN CHEST] Goal reached ({c_current}/{c_max})! Clicking crown chest at (875, 1260)...") - self.click_client_pos(875, 1260) + logging.info(f"[CROWN CHEST] Goal reached ({c_current}/{c_max})! Clicking crown chest...") + cx = int(self.REL_CROWN_CHEST_POS[0] * fw) + cy = int(self.REL_CROWN_CHEST_POS[1] * fh) + self.click_client_pos(cx, cy) time.sleep(1.5) return @@ -471,7 +498,7 @@ class DirtyLeagueBot: return logging.info("Searching for 'btn_fight' / 'btn_play'...") - fight_pos = self.find_template_in_roi(frame, "btn_fight", (3000, 750, 3550, 1000)) or \ + fight_pos = self.find_template_in_rel_roi(frame, "btn_fight", self.REL_ROI_FIGHT) or \ self.find_template(frame, "btn_fight") or \ self.find_template(frame, "btn_play") if fight_pos: @@ -499,7 +526,12 @@ class DirtyLeagueBot: self.current_wins += 1 self.total_games += 1 logging.info(f"[WIN] Victory recorded! Streak: {self.current_wins}/{self.max_wins_limit}. Total games: {self.total_games}") - if not self.click_template(frame, "btn_collect_victory"): + + collect_pos = self.find_template_in_rel_roi(frame, "btn_collect_victory", self.REL_ROI_VICTORY_COLLECT) or \ + self.find_template(frame, "btn_collect_victory") + if collect_pos: + self.click_client_pos(collect_pos[0], collect_pos[1]) + else: self.click_template(frame, "btn_continue") time.sleep(1.0) @@ -509,17 +541,26 @@ class DirtyLeagueBot: self.current_wins = 0 self.total_games += 1 logging.info(f"[LOSS] Streak reset to 0. Total games: {self.total_games}") - if not self.click_template(frame, "btn_ok"): + + ok_pos = self.find_template_in_rel_roi(frame, "btn_ok", self.REL_ROI_DEFEAT_OK) or \ + self.find_template(frame, "btn_ok") + if ok_pos: + self.click_client_pos(ok_pos[0], ok_pos[1]) + else: self.click_template(frame, "btn_continue") time.sleep(1.0) def handle_no_free_slots(self, frame: np.ndarray): logging.info("State: NO_FREE_SLOTS_SCREEN ('You have no slot available for this chest').") - open_pos = self.find_template(frame, "btn_open_chest") + open_pos = self.find_template_in_rel_roi(frame, "btn_open_chest", self.REL_ROI_NO_SLOTS_OPEN) or \ + self.find_template(frame, "btn_open_chest") if open_pos: logging.debug(f"[NO_FREE_SLOTS] 'Open chest' option detected at {open_pos}.") - if self.click_template(frame, "btn_remove_chest"): + remove_pos = self.find_template_in_rel_roi(frame, "btn_remove_chest", self.REL_ROI_NO_SLOTS_REMOVE) or \ + self.find_template(frame, "btn_remove_chest") + if remove_pos: + self.click_client_pos(remove_pos[0], remove_pos[1]) logging.info("Clicked 'Remove chest' to discard unslotted chest. Returning to lobby...") time.sleep(1.0) diff --git a/ocr_utils.py b/ocr_utils.py index fe667e1..44b3037 100644 --- a/ocr_utils.py +++ b/ocr_utils.py @@ -62,14 +62,19 @@ class OcrReader: """ Extracts trophies counter (current, max) from TOWER_LOBBY screen. Expected format: '2125/2300' -> returns (2125, 2300). + Uses relative coordinates (0.2634..0.3171 y, 0.8151..0.9115 x) to support any resolution. """ - # ROI for trophy numbers above FIGHT button - # y: 540 to 650, x: 3130 to 3500 fh, fw = frame.shape[:2] - if fh < 1000 or fw < 2000: + if fh < 300 or fw < 500: return None - trophy_crop = frame[540:650, 3130:3500] + y1, y2 = int(fh * 0.2634), int(fh * 0.3171) + x1, x2 = int(fw * 0.8151), int(fw * 0.9115) + trophy_crop = frame[y1:y2, x1:x2] + + if trophy_crop.shape[0] < 80: + trophy_crop = cv2.resize(trophy_crop, (0, 0), fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC) + text = self.read_text(trophy_crop) # Clean up text and parse @@ -85,35 +90,54 @@ class OcrReader: """ Extracts crown chest counter (current, max) from TOWER_LOBBY screen. Expected format: '3/5' or '5/5' -> returns (current, 5). + Uses relative coordinates and dynamically scaled templates to support any resolution. """ fh, fw = frame.shape[:2] - if fh < 1000 or fw < 2000: + if fh < 300 or fw < 500: return None + scale = fw / 3840.0 + + # Relative ROI for crown digit badge + y1, y2 = int(fh * 0.6780), int(fh * 0.7146) + x1, x2 = int(fw * 0.2057), int(fw * 0.2214) + digit_roi = frame[y1:y2, x1:x2] + + assets_dir = os.path.join(os.path.dirname(__file__), "assets") + # 1. Fast template check for 5/5 (critical trigger for opening crown chest) - digit_roi = frame[1390:1465, 790:850] - tpl_5_path = os.path.join(os.path.dirname(__file__), "assets", "digit_crown_5.png") + tpl_5_path = os.path.join(assets_dir, "digit_crown_5.png") if os.path.exists(tpl_5_path): tpl_5 = cv2.imread(tpl_5_path) - if tpl_5 is not None and digit_roi.shape[0] >= tpl_5.shape[0] and digit_roi.shape[1] >= tpl_5.shape[1]: - res_5 = cv2.matchTemplate(digit_roi, tpl_5, cv2.TM_CCOEFF_NORMED) - _, max_v5, _, _ = cv2.minMaxLoc(res_5) - if max_v5 >= 0.88: - return 5, 5 + if tpl_5 is not None: + if abs(scale - 1.0) > 0.02: + interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC + tpl_5 = cv2.resize(tpl_5, (0, 0), fx=scale, fy=scale, interpolation=interp) + if digit_roi.shape[0] >= tpl_5.shape[0] and digit_roi.shape[1] >= tpl_5.shape[1]: + res_5 = cv2.matchTemplate(digit_roi, tpl_5, cv2.TM_CCOEFF_NORMED) + _, max_v5, _, _ = cv2.minMaxLoc(res_5) + if max_v5 >= 0.85: + return 5, 5 # 2. Template check for 3/5 - tpl_3_path = os.path.join(os.path.dirname(__file__), "assets", "digit_crown_3.png") + tpl_3_path = os.path.join(assets_dir, "digit_crown_3.png") if os.path.exists(tpl_3_path): tpl_3 = cv2.imread(tpl_3_path) - if tpl_3 is not None and digit_roi.shape[0] >= tpl_3.shape[0] and digit_roi.shape[1] >= tpl_3.shape[1]: - res_3 = cv2.matchTemplate(digit_roi, tpl_3, cv2.TM_CCOEFF_NORMED) - _, max_v3, _, _ = cv2.minMaxLoc(res_3) - if max_v3 >= 0.88: - return 3, 5 + if tpl_3 is not None: + if abs(scale - 1.0) > 0.02: + interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC + tpl_3 = cv2.resize(tpl_3, (0, 0), fx=scale, fy=scale, interpolation=interp) + if digit_roi.shape[0] >= tpl_3.shape[0] and digit_roi.shape[1] >= tpl_3.shape[1]: + res_3 = cv2.matchTemplate(digit_roi, tpl_3, cv2.TM_CCOEFF_NORMED) + _, max_v3, _, _ = cv2.minMaxLoc(res_3) + if max_v3 >= 0.85: + return 3, 5 # 3. Fallback to Windows native OCR - badge_crop = frame[1380:1470, 740:1000] - w = 1000 - 740 + by1, by2 = int(fh * 0.6732), int(fh * 0.7171) + bx1, bx2 = int(fw * 0.1927), int(fw * 0.2604) + badge_crop = frame[by1:by2, bx1:bx2] + w = badge_crop.shape[1] text_part = badge_crop[:, :int(w * 0.65)] up = cv2.resize(text_part, (0, 0), fx=3.0, fy=3.0, interpolation=cv2.INTER_CUBIC) text = self.read_text(up) diff --git a/test_resolution_scaling.py b/test_resolution_scaling.py new file mode 100644 index 0000000..f5bea96 --- /dev/null +++ b/test_resolution_scaling.py @@ -0,0 +1,114 @@ +""" +Automated unit & integration test for Resolution Scaling and Relative Coordinates. +Tests that detect_state, OCR, and chest scanning correctly recognize all screens +at 4K, 1440p, 1080p, and 720p resolutions. +""" + +import json +import os +import cv2 +from bot_core import DirtyLeagueBot, GameState +import ocr_utils + +CONFIG_PATH = "config.json" +with open(CONFIG_PATH, "r", encoding="utf-8") as f: + config = json.load(f) + +bot = DirtyLeagueBot(config) + +SNAPSHOTS = { + "IN_GAME (Battle)": ("assets/battle_screen_snapshot.png", GameState.IN_GAME), + "IN_GAME (Leave)": ("assets/leave_screen_snapshot.png", GameState.IN_GAME), + "TOWER_LOBBY": ("assets/tower_screen_snapshot.png", GameState.TOWER_LOBBY), + "DEFEAT_SCREEN": ("assets/defeat_screen_snapshot.png", GameState.DEFEAT_SCREEN), + "VICTORY_SCREEN": ("assets/victory_screen_snapshot.png", GameState.VICTORY_SCREEN), + "NO_FREE_SLOTS": ("assets/no_free_slots_snapshot.png", GameState.NO_FREE_SLOTS_SCREEN), + "CHEST_OPEN": ("assets/chest_reward_snapshot.png", GameState.CHEST_OPEN_SCREEN), +} + +RESOLUTIONS = [ + ("Native 4K", 3840, 2050), + ("2K / 1440p", 2560, 1366), + ("FullHD / 1080p", 1920, 1025), + ("HD / 720p", 1280, 683), +] + + +def run_tests(): + print("=" * 70) + print(" TESTING RESOLUTION INDEPENDENCE AND RELATIVE COORDINATES ") + print("=" * 70) + + total_passed = 0 + total_tests = 0 + + for res_name, target_w, target_h in RESOLUTIONS: + print(f"\n--- Testing at resolution: {res_name} ({target_w}x{target_h}) ---") + for screen_name, (path, expected_state) in SNAPSHOTS.items(): + if not os.path.exists(path): + print(f"[SKIP] Snapshot not found: {path}") + continue + + orig_img = cv2.imread(path) + if orig_img.shape[1] == target_w and orig_img.shape[0] == target_h: + tested_frame = orig_img + else: + tested_frame = cv2.resize(orig_img, (target_w, target_h), interpolation=cv2.INTER_AREA) + + detected = bot.detect_state(tested_frame) + is_ok = (detected == expected_state) + status_str = "PASS" if is_ok else "FAIL" + print(f" [{status_str}] {screen_name:18} -> detected: {detected.value:20} (expected: {expected_state.value})") + + total_tests += 1 + if is_ok: + total_passed += 1 + + # Test OCR across resolutions + print("\n--- Testing OCR Scaling ---") + tower_4k = cv2.imread("assets/tower_screen_snapshot.png") + for res_name, target_w, target_h in RESOLUTIONS: + frame = cv2.resize(tower_4k, (target_w, target_h), interpolation=cv2.INTER_AREA) + trophies = ocr_utils.ocr_reader.read_trophies(frame) + is_ok = (trophies == (1925, 2300)) + status_str = "PASS" if is_ok else "FAIL" + print(f" [{status_str}] {res_name:14} Trophies OCR: {trophies} (expected: (1925, 2300))") + total_tests += 1 + if is_ok: + total_passed += 1 + + # Test Crown Chest Badge (5/5) across resolutions + print("\n--- Testing Crown Chest 5/5 Badge Scaling ---") + crown_4k = cv2.imread("assets/tower_lobby_crown_filled.png") + for res_name, target_w, target_h in RESOLUTIONS: + frame = cv2.resize(crown_4k, (target_w, target_h), interpolation=cv2.INTER_AREA) + crown = ocr_utils.ocr_reader.read_crown_chest(frame) + is_ok = (crown == (5, 5)) + status_str = "PASS" if is_ok else "FAIL" + print(f" [{status_str}] {res_name:14} Crown 5/5 Badge: {crown} (expected: (5, 5))") + total_tests += 1 + if is_ok: + total_passed += 1 + + # Test Bottom Chests Grid across resolutions + print("\n--- Testing Bottom Chests Grid Scanning ---") + for res_name, target_w, target_h in RESOLUTIONS: + frame = cv2.resize(tower_4k, (target_w, target_h), interpolation=cv2.INTER_AREA) + chests = bot.scan_bottom_chests(frame) + statuses = {k: v["status"] for k, v in chests.items()} + # Slots 1 and 3 are OPEN on tower_screen_snapshot + is_ok = (statuses[1] == "OPEN" and statuses[3] == "OPEN") + status_str = "PASS" if is_ok else "FAIL" + print(f" [{status_str}] {res_name:14} Chests: {statuses}") + total_tests += 1 + if is_ok: + total_passed += 1 + + print("\n" + "=" * 70) + print(f" TOTAL RESULT: {total_passed} / {total_tests} tests passed ({total_passed / total_tests * 100:.1f}%)") + print("=" * 70) + assert total_passed == total_tests, f"Only {total_passed}/{total_tests} tests passed!" + + +if __name__ == "__main__": + run_tests() diff --git a/window_utils.py b/window_utils.py index 347a08d..1aace21 100644 --- a/window_utils.py +++ b/window_utils.py @@ -193,6 +193,21 @@ class WindowManager: c_left, c_top, _, _ = self.get_client_rect() return c_left + x, c_top + y + def rel_to_client(self, rx: float, ry: float) -> Tuple[int, int]: + """Translates normalized relative coordinates (0.0..1.0) to client pixel coordinates (x, y).""" + _, _, width, height = self.get_client_rect() + return int(rx * width), int(ry * height) + + def rel_to_client_rect(self, rx1: float, ry1: float, rx2: float, ry2: float) -> Tuple[int, int, int, int]: + """Translates normalized relative ROI (rx1, ry1, rx2, ry2) to client pixel rect (x1, y1, x2, y2).""" + _, _, width, height = self.get_client_rect() + return int(rx1 * width), int(ry1 * height), int(rx2 * width), int(ry2 * height) + + def rel_to_screen(self, rx: float, ry: float) -> Tuple[int, int]: + """Translates normalized relative coordinates (0.0..1.0) directly to absolute screen coordinates.""" + cx, cy = self.rel_to_client(rx, ry) + return self.client_to_screen(cx, cy) + if __name__ == "__main__": wm = WindowManager()