diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16df885 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Virtual Environment +.venv/ +env/ +venv/ + +# Python cache +__pycache__/ +*.py[cod] +*$py.class + +# IDE and OS +.vscode/ +.idea/ +Thumbs.db +Desktop.ini + +# Logs & temp captures +logs/ +*.log +temp_*.png +debug_*.png diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..f86f928 --- /dev/null +++ b/assets/.gitkeep @@ -0,0 +1 @@ +# Assets directory for template images diff --git a/assets/anchor_dungeons.png b/assets/anchor_dungeons.png new file mode 100644 index 0000000..2827364 Binary files /dev/null and b/assets/anchor_dungeons.png differ diff --git a/assets/anchor_main_menu.png b/assets/anchor_main_menu.png new file mode 100644 index 0000000..e8f18ad Binary files /dev/null and b/assets/anchor_main_menu.png differ diff --git a/assets/anchor_tower_league.png b/assets/anchor_tower_league.png new file mode 100644 index 0000000..c989d90 Binary files /dev/null and b/assets/anchor_tower_league.png differ diff --git a/assets/badge_crown_5_5.png b/assets/badge_crown_5_5.png new file mode 100644 index 0000000..e3f2e40 Binary files /dev/null and b/assets/badge_crown_5_5.png differ diff --git a/assets/badge_exclamation.png b/assets/badge_exclamation.png new file mode 100644 index 0000000..4de4aca Binary files /dev/null and b/assets/badge_exclamation.png differ diff --git a/assets/banner_defeat.png b/assets/banner_defeat.png new file mode 100644 index 0000000..12d6458 Binary files /dev/null and b/assets/banner_defeat.png differ diff --git a/assets/banner_victory.png b/assets/banner_victory.png new file mode 100644 index 0000000..03b58e7 Binary files /dev/null and b/assets/banner_victory.png differ diff --git a/assets/battle_screen_snapshot.png b/assets/battle_screen_snapshot.png new file mode 100644 index 0000000..236bd4d Binary files /dev/null and b/assets/battle_screen_snapshot.png differ diff --git a/assets/btn_auto.png b/assets/btn_auto.png new file mode 100644 index 0000000..40ddb40 Binary files /dev/null and b/assets/btn_auto.png differ diff --git a/assets/btn_back.png b/assets/btn_back.png new file mode 100644 index 0000000..a3bd9db Binary files /dev/null and b/assets/btn_back.png differ diff --git a/assets/btn_close.png b/assets/btn_close.png new file mode 100644 index 0000000..d84f9c8 Binary files /dev/null and b/assets/btn_close.png differ diff --git a/assets/btn_close_offer.png b/assets/btn_close_offer.png new file mode 100644 index 0000000..d84f9c8 Binary files /dev/null and b/assets/btn_close_offer.png differ diff --git a/assets/btn_collect.png b/assets/btn_collect.png new file mode 100644 index 0000000..b3b81e8 Binary files /dev/null and b/assets/btn_collect.png differ diff --git a/assets/btn_collect_text.png b/assets/btn_collect_text.png new file mode 100644 index 0000000..e5c5b1b Binary files /dev/null and b/assets/btn_collect_text.png differ diff --git a/assets/btn_collect_victory.png b/assets/btn_collect_victory.png new file mode 100644 index 0000000..82d7e2f Binary files /dev/null and b/assets/btn_collect_victory.png differ diff --git a/assets/btn_exit.png b/assets/btn_exit.png new file mode 100644 index 0000000..ef2a44d Binary files /dev/null and b/assets/btn_exit.png differ diff --git a/assets/btn_fight.png b/assets/btn_fight.png new file mode 100644 index 0000000..7512b55 Binary files /dev/null and b/assets/btn_fight.png differ diff --git a/assets/btn_leave.png b/assets/btn_leave.png new file mode 100644 index 0000000..aceb750 Binary files /dev/null and b/assets/btn_leave.png differ diff --git a/assets/btn_mode_tower.png b/assets/btn_mode_tower.png new file mode 100644 index 0000000..cc67eea Binary files /dev/null and b/assets/btn_mode_tower.png differ diff --git a/assets/btn_ok.png b/assets/btn_ok.png new file mode 100644 index 0000000..d5a6f0f Binary files /dev/null and b/assets/btn_ok.png differ diff --git a/assets/btn_open_chest.png b/assets/btn_open_chest.png new file mode 100644 index 0000000..f39c992 Binary files /dev/null and b/assets/btn_open_chest.png differ diff --git a/assets/btn_remove_chest.png b/assets/btn_remove_chest.png new file mode 100644 index 0000000..202297c Binary files /dev/null and b/assets/btn_remove_chest.png differ diff --git a/assets/btn_turn_all_cards.png b/assets/btn_turn_all_cards.png new file mode 100644 index 0000000..a0edc85 Binary files /dev/null and b/assets/btn_turn_all_cards.png differ diff --git a/assets/btn_turn_all_cards_text.png b/assets/btn_turn_all_cards_text.png new file mode 100644 index 0000000..32715ab Binary files /dev/null and b/assets/btn_turn_all_cards_text.png differ diff --git a/assets/char_0.png b/assets/char_0.png new file mode 100644 index 0000000..ed34e3a Binary files /dev/null and b/assets/char_0.png differ diff --git a/assets/char_1.png b/assets/char_1.png new file mode 100644 index 0000000..1b9beb2 Binary files /dev/null and b/assets/char_1.png differ diff --git a/assets/char_2.png b/assets/char_2.png new file mode 100644 index 0000000..0b0918c Binary files /dev/null and b/assets/char_2.png differ diff --git a/assets/char_3.png b/assets/char_3.png new file mode 100644 index 0000000..baa555e Binary files /dev/null and b/assets/char_3.png differ diff --git a/assets/char_4.png b/assets/char_4.png new file mode 100644 index 0000000..fbf4c4a Binary files /dev/null and b/assets/char_4.png differ diff --git a/assets/char_5.png b/assets/char_5.png new file mode 100644 index 0000000..b378bb6 Binary files /dev/null and b/assets/char_5.png differ diff --git a/assets/char_6.png b/assets/char_6.png new file mode 100644 index 0000000..5c7b280 Binary files /dev/null and b/assets/char_6.png differ diff --git a/assets/char_7.png b/assets/char_7.png new file mode 100644 index 0000000..686c72d Binary files /dev/null and b/assets/char_7.png differ diff --git a/assets/char_8.png b/assets/char_8.png new file mode 100644 index 0000000..a43d66a Binary files /dev/null and b/assets/char_8.png differ diff --git a/assets/chars_annotated.png b/assets/chars_annotated.png new file mode 100644 index 0000000..f403809 Binary files /dev/null and b/assets/chars_annotated.png differ diff --git a/assets/chest_collect_snapshot.png b/assets/chest_collect_snapshot.png new file mode 100644 index 0000000..7f784f7 Binary files /dev/null and b/assets/chest_collect_snapshot.png differ diff --git a/assets/chest_reward_snapshot.png b/assets/chest_reward_snapshot.png new file mode 100644 index 0000000..6f4ef8f Binary files /dev/null and b/assets/chest_reward_snapshot.png differ diff --git a/assets/crown_5_5_up.png b/assets/crown_5_5_up.png new file mode 100644 index 0000000..d1e0805 Binary files /dev/null and b/assets/crown_5_5_up.png differ diff --git a/assets/crown_badge_area.png b/assets/crown_badge_area.png new file mode 100644 index 0000000..fa28673 Binary files /dev/null and b/assets/crown_badge_area.png differ diff --git a/assets/crown_badge_exact.png b/assets/crown_badge_exact.png new file mode 100644 index 0000000..4772c45 Binary files /dev/null and b/assets/crown_badge_exact.png differ diff --git a/assets/crown_badge_filled_area.png b/assets/crown_badge_filled_area.png new file mode 100644 index 0000000..b3639d1 Binary files /dev/null and b/assets/crown_badge_filled_area.png differ diff --git a/assets/crown_bin.png b/assets/crown_bin.png new file mode 100644 index 0000000..559cd49 Binary files /dev/null and b/assets/crown_bin.png differ diff --git a/assets/crown_chest_area.png b/assets/crown_chest_area.png new file mode 100644 index 0000000..229c8ed Binary files /dev/null and b/assets/crown_chest_area.png differ diff --git a/assets/crown_chest_filled_area.png b/assets/crown_chest_filled_area.png new file mode 100644 index 0000000..bd49a05 Binary files /dev/null and b/assets/crown_chest_filled_area.png differ diff --git a/assets/crown_chest_open_snapshot.png b/assets/crown_chest_open_snapshot.png new file mode 100644 index 0000000..9e56930 Binary files /dev/null and b/assets/crown_chest_open_snapshot.png differ diff --git a/assets/crown_inv.png b/assets/crown_inv.png new file mode 100644 index 0000000..db00d9e Binary files /dev/null and b/assets/crown_inv.png differ diff --git a/assets/crown_padded.png b/assets/crown_padded.png new file mode 100644 index 0000000..cdd48c2 Binary files /dev/null and b/assets/crown_padded.png differ diff --git a/assets/crown_text_part.png b/assets/crown_text_part.png new file mode 100644 index 0000000..01f8cba Binary files /dev/null and b/assets/crown_text_part.png differ diff --git a/assets/crown_thresh.png b/assets/crown_thresh.png new file mode 100644 index 0000000..fb016e5 Binary files /dev/null and b/assets/crown_thresh.png differ diff --git a/assets/crown_up.png b/assets/crown_up.png new file mode 100644 index 0000000..5916ebe Binary files /dev/null and b/assets/crown_up.png differ diff --git a/assets/defeat_buttons_crop.png b/assets/defeat_buttons_crop.png new file mode 100644 index 0000000..b4d4afe Binary files /dev/null and b/assets/defeat_buttons_crop.png differ diff --git a/assets/defeat_screen_snapshot.png b/assets/defeat_screen_snapshot.png new file mode 100644 index 0000000..e7b9fbc Binary files /dev/null and b/assets/defeat_screen_snapshot.png differ diff --git a/assets/detected_close.png b/assets/detected_close.png new file mode 100644 index 0000000..65dc753 Binary files /dev/null and b/assets/detected_close.png differ diff --git a/assets/digit_crown_3.png b/assets/digit_crown_3.png new file mode 100644 index 0000000..3ad0e98 Binary files /dev/null and b/assets/digit_crown_3.png differ diff --git a/assets/digit_crown_5.png b/assets/digit_crown_5.png new file mode 100644 index 0000000..d9079ff Binary files /dev/null and b/assets/digit_crown_5.png differ diff --git a/assets/leave_screen_snapshot.png b/assets/leave_screen_snapshot.png new file mode 100644 index 0000000..fa85c76 Binary files /dev/null and b/assets/leave_screen_snapshot.png differ diff --git a/assets/main_menu_calibrated.png b/assets/main_menu_calibrated.png new file mode 100644 index 0000000..6508a49 Binary files /dev/null and b/assets/main_menu_calibrated.png differ diff --git a/assets/no_free_slots_buttons_crop.png b/assets/no_free_slots_buttons_crop.png new file mode 100644 index 0000000..540ee64 Binary files /dev/null and b/assets/no_free_slots_buttons_crop.png differ diff --git a/assets/no_free_slots_buttons_crop2.png b/assets/no_free_slots_buttons_crop2.png new file mode 100644 index 0000000..eab84fb Binary files /dev/null and b/assets/no_free_slots_buttons_crop2.png differ diff --git a/assets/no_free_slots_snapshot.png b/assets/no_free_slots_snapshot.png new file mode 100644 index 0000000..538bde2 Binary files /dev/null and b/assets/no_free_slots_snapshot.png differ diff --git a/assets/offer_screen_snapshot.png b/assets/offer_screen_snapshot.png new file mode 100644 index 0000000..11c7c58 Binary files /dev/null and b/assets/offer_screen_snapshot.png differ diff --git a/assets/region_full_btn.png b/assets/region_full_btn.png new file mode 100644 index 0000000..d03bf9a Binary files /dev/null and b/assets/region_full_btn.png differ diff --git a/assets/slot_1.png b/assets/slot_1.png new file mode 100644 index 0000000..d52bb76 Binary files /dev/null and b/assets/slot_1.png differ diff --git a/assets/slot_1_status.png b/assets/slot_1_status.png new file mode 100644 index 0000000..b98d5be Binary files /dev/null and b/assets/slot_1_status.png differ diff --git a/assets/slot_1_vacant.png b/assets/slot_1_vacant.png new file mode 100644 index 0000000..e628693 Binary files /dev/null and b/assets/slot_1_vacant.png differ diff --git a/assets/slot_2.png b/assets/slot_2.png new file mode 100644 index 0000000..8f85d5a Binary files /dev/null and b/assets/slot_2.png differ diff --git a/assets/slot_2_status.png b/assets/slot_2_status.png new file mode 100644 index 0000000..6ea102b Binary files /dev/null and b/assets/slot_2_status.png differ diff --git a/assets/slot_3.png b/assets/slot_3.png new file mode 100644 index 0000000..1f69905 Binary files /dev/null and b/assets/slot_3.png differ diff --git a/assets/slot_3_status.png b/assets/slot_3_status.png new file mode 100644 index 0000000..fcdf638 Binary files /dev/null and b/assets/slot_3_status.png differ diff --git a/assets/slot_4.png b/assets/slot_4.png new file mode 100644 index 0000000..c1930ce Binary files /dev/null and b/assets/slot_4.png differ diff --git a/assets/slot_4_status.png b/assets/slot_4_status.png new file mode 100644 index 0000000..388c49d Binary files /dev/null and b/assets/slot_4_status.png differ diff --git a/assets/slot_5.png b/assets/slot_5.png new file mode 100644 index 0000000..507e797 Binary files /dev/null and b/assets/slot_5.png differ diff --git a/assets/slot_5_status.png b/assets/slot_5_status.png new file mode 100644 index 0000000..ca3dbf9 Binary files /dev/null and b/assets/slot_5_status.png differ diff --git a/assets/status_open.png b/assets/status_open.png new file mode 100644 index 0000000..d4594c9 Binary files /dev/null and b/assets/status_open.png differ diff --git a/assets/status_vacant.png b/assets/status_vacant.png new file mode 100644 index 0000000..b45c5f0 Binary files /dev/null and b/assets/status_vacant.png differ diff --git a/assets/text_crown_5_5.png b/assets/text_crown_5_5.png new file mode 100644 index 0000000..30c3476 Binary files /dev/null and b/assets/text_crown_5_5.png differ diff --git a/assets/text_defeat.png b/assets/text_defeat.png new file mode 100644 index 0000000..18b4b21 Binary files /dev/null and b/assets/text_defeat.png differ diff --git a/assets/text_no_slot_available.png b/assets/text_no_slot_available.png new file mode 100644 index 0000000..d60b289 Binary files /dev/null and b/assets/text_no_slot_available.png differ diff --git a/assets/text_victory.png b/assets/text_victory.png new file mode 100644 index 0000000..e6e9725 Binary files /dev/null and b/assets/text_victory.png differ diff --git a/assets/tile_tower_label.png b/assets/tile_tower_label.png new file mode 100644 index 0000000..9fd0c94 Binary files /dev/null and b/assets/tile_tower_label.png differ diff --git a/assets/title_special_offer.png b/assets/title_special_offer.png new file mode 100644 index 0000000..f36f8cf Binary files /dev/null and b/assets/title_special_offer.png differ diff --git a/assets/tower_lobby_after_chest.png b/assets/tower_lobby_after_chest.png new file mode 100644 index 0000000..5a0b30e Binary files /dev/null and b/assets/tower_lobby_after_chest.png differ diff --git a/assets/tower_lobby_crown_check.png b/assets/tower_lobby_crown_check.png new file mode 100644 index 0000000..2cfc087 Binary files /dev/null and b/assets/tower_lobby_crown_check.png differ diff --git a/assets/tower_lobby_crown_filled.png b/assets/tower_lobby_crown_filled.png new file mode 100644 index 0000000..7eeace0 Binary files /dev/null and b/assets/tower_lobby_crown_filled.png differ diff --git a/assets/tower_screen_snapshot.png b/assets/tower_screen_snapshot.png new file mode 100644 index 0000000..9914235 Binary files /dev/null and b/assets/tower_screen_snapshot.png differ diff --git a/assets/trophies_check_snapshot.png b/assets/trophies_check_snapshot.png new file mode 100644 index 0000000..d6180ff Binary files /dev/null and b/assets/trophies_check_snapshot.png differ diff --git a/assets/trophy_area.png b/assets/trophy_area.png new file mode 100644 index 0000000..fc7107a Binary files /dev/null and b/assets/trophy_area.png differ diff --git a/assets/trophy_numbers.png b/assets/trophy_numbers.png new file mode 100644 index 0000000..c95a6cb Binary files /dev/null and b/assets/trophy_numbers.png differ diff --git a/assets/trophy_thresh.png b/assets/trophy_thresh.png new file mode 100644 index 0000000..92c30a4 Binary files /dev/null and b/assets/trophy_thresh.png differ diff --git a/assets/victory_collect_test.png b/assets/victory_collect_test.png new file mode 100644 index 0000000..192f148 Binary files /dev/null and b/assets/victory_collect_test.png differ diff --git a/assets/victory_screen_snapshot.png b/assets/victory_screen_snapshot.png new file mode 100644 index 0000000..d4de242 Binary files /dev/null and b/assets/victory_screen_snapshot.png differ diff --git a/bot_core.py b/bot_core.py new file mode 100644 index 0000000..60b71a8 --- /dev/null +++ b/bot_core.py @@ -0,0 +1,588 @@ +""" +Core FSM Bot engine for DirtyLeague. +Manages screen recognition, game state transitions, and automated inputs. +""" + +from enum import Enum +import logging +import os +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 + + +class GameState(Enum): + UNKNOWN = "UNKNOWN" + MAIN_MENU = "MAIN_MENU" + TOWER_LOBBY = "TOWER_LOBBY" + CHEST_OPEN_SCREEN = "CHEST_OPEN_SCREEN" + IN_GAME = "IN_GAME" + VICTORY_SCREEN = "VICTORY_SCREEN" + DEFEAT_SCREEN = "DEFEAT_SCREEN" + NO_FREE_SLOTS_SCREEN = "NO_FREE_SLOTS_SCREEN" + + +class DirtyLeagueBot: + def __init__(self, config: dict): + self.config = config + self.bot_conf = config.get("bot", {}) + self.target_conf = config.get("target_window", {}) + self.assets_dir = config.get("assets_dir", "assets") + + self.max_wins_limit = self.bot_conf.get("max_wins_limit", 3) + self.poll_interval = self.bot_conf.get("poll_interval_sec", 0.8) + self.confidence_threshold = self.bot_conf.get("confidence_threshold", 0.8) + self.action_delay = self.bot_conf.get("action_delay_sec", 0.5) + + self.current_wins = 0 + self.total_games = 0 + self.current_trophies: Optional[int] = None + self.max_trophies: Optional[int] = None + + self.league_conf = config.get("league_retention", {}) + self.upper_trophies = self.league_conf.get("upper_trophy_threshold", 2125) + self.lower_trophies = self.league_conf.get("lower_trophy_threshold", 125) + self.derank_mode: bool = False + self._result_recorded_for_match: bool = False + + self.running = False + self.state = GameState.UNKNOWN + + self.wm = WindowManager( + window_title=self.target_conf.get("title", "DirtyLeague"), + process_name=self.target_conf.get("process_name", "DirtyLeague.exe") + ) + self.templates: Dict[str, np.ndarray] = {} + self._load_templates() + + def _load_templates(self): + """Preloads all 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): + if file_name.endswith(".png"): + name_without_ext = os.path.splitext(file_name)[0] + 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())}") + + def find_template(self, frame: np.ndarray, template_name: str, threshold: Optional[float] = None) -> Optional[Tuple[int, int]]: + """ + Searches for template_name in frame. + Returns client center coordinates (center_x, center_y) if matched above threshold, else None. + """ + if template_name not in self.templates: + # Try reloading in case new template was added + 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: + return None + + tpl = self.templates[template_name] + th, tw = tpl.shape[:2] + fh, fw = frame.shape[:2] + if th > fh or tw > fw: + return None + + res = cv2.matchTemplate(frame, tpl, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(res) + + target_thresh = threshold if threshold is not None else self.confidence_threshold + if max_val >= target_thresh: + center_x = max_loc[0] + tw // 2 + center_y = max_loc[1] + th // 2 + logging.debug(f"Matched '{template_name}' (conf: {max_val:.3f}) at client ({center_x}, {center_y})") + return center_x, center_y + + 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]]: + """ + Fast template search restricted to ROI: (x1, y1, x2, y2). + Returns client center coordinates (cx, cy) if matched, else None. + Runs in ~2-5ms instead of ~420ms for full-frame search. + """ + if template_name not in self.templates: + return None + tpl = self.templates[template_name] + th, tw = tpl.shape[:2] + fh, fw = frame.shape[:2] + + 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: + return None + + crop = frame[y1:y2, x1:x2] + res = cv2.matchTemplate(crop, tpl, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(res) + + target_thresh = threshold if threshold is not None else self.confidence_threshold + if max_val >= target_thresh: + cx = x1 + max_loc[0] + tw // 2 + cy = y1 + max_loc[1] + th // 2 + return cx, cy + return None + + 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) + logging.info(f"Clicking at client ({x}, {y}) -> screen ({screen_x}, {screen_y})") + from window_utils import ensure_interactive_desktop, user32 + ensure_interactive_desktop() + self.wm.focus() + user32.SetCursorPos(screen_x, screen_y) + time.sleep(0.08) + user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN + time.sleep(0.08) + user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP + time.sleep(self.action_delay) + + def click_client_pos_fast(self, x: int, y: int): + """Ultra-fast click without artificial action delays for time-critical reactions (e.g. instant surrender).""" + screen_x, screen_y = self.wm.client_to_screen(x, y) + from window_utils import ensure_interactive_desktop, user32 + ensure_interactive_desktop() + self.wm.focus() + user32.SetCursorPos(screen_x, screen_y) + time.sleep(0.02) + user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN + time.sleep(0.02) + user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP + + def click_template(self, frame: np.ndarray, template_name: str, threshold: Optional[float] = None) -> bool: + """Finds and clicks a template in the frame if found.""" + pos = self.find_template(frame, template_name, threshold) + if pos: + self.click_client_pos(pos[0], pos[1]) + return True + 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)): + 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): + 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)): + return GameState.IN_GAME + + if self.find_template_in_roi(frame, "btn_fight", (3000, 750, 3550, 1000)): + 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): + 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): + return GameState.VICTORY_SCREEN + + # 2. Full-frame fallback checks if ROI didn't trigger: + if self.find_template(frame, "btn_remove_chest") or self.find_template(frame, "btn_open_chest"): + return GameState.NO_FREE_SLOTS_SCREEN + if self.find_template(frame, "text_victory") or self.find_template(frame, "banner_victory") or self.find_template(frame, "btn_collect_victory"): + return GameState.VICTORY_SCREEN + if self.find_template(frame, "text_defeat", threshold=0.65) or self.find_template(frame, "btn_ok"): + return GameState.DEFEAT_SCREEN + if self.find_template(frame, "btn_leave") or self.find_template(frame, "btn_exit") or self.find_template(frame, "btn_pause"): + return GameState.IN_GAME + if self.find_template(frame, "btn_collect") or self.find_template(frame, "btn_turn_all_cards"): + return GameState.CHEST_OPEN_SCREEN + if self.find_template(frame, "btn_fight") or self.find_template(frame, "btn_play"): + return GameState.TOWER_LOBBY + if self.find_template(frame, "anchor_main_menu") or self.find_template(frame, "btn_mode_tower"): + return GameState.MAIN_MENU + + return GameState.UNKNOWN + + 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)} + """ + results = {} + slot_pitch = 213 + base_x = 525 + y = 1850 + w = 210 + h = 70 + + for i in range(4): + slot_id = i + 1 + sx = base_x + i * slot_pitch + status_crop = frame[y:y + h, sx:sx + w] + + 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 + + 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" + + results[slot_id] = { + "status": status, + "click_pos": (sx + w // 2, y - 70) + } + 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. + """ + # 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) + 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"): + 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"): + logging.info("Clicked 'Collect'. Rewards claimed, returning to lobby...") + time.sleep(1.5) + # Step 3: Check for promotional offer popup stub + after_frame = self.wm.capture_frame(client_only=True) + self.check_and_dismiss_offer_popup(after_frame) + + def handle_main_menu(self, frame: np.ndarray): + logging.info("State: MAIN_MENU. Navigating to TOWER mode...") + if self.click_template(frame, "btn_mode_tower"): + logging.info("Clicked 'btn_mode_tower'. Transitioning to TOWER...") + time.sleep(1.5) + + def update_league_strategy(self): + """Updates derank_mode based on current trophies and thresholds.""" + if self.current_trophies is None: + return + + if not self.derank_mode and self.current_trophies >= self.upper_trophies: + self.derank_mode = True + logging.warning( + f"[LEAGUE STRATEGY] Upper threshold reached ({self.current_trophies} >= {self.upper_trophies}). " + f"ACTIVATING FORCED DERANK MODE! (Will surrender matches until <= {self.lower_trophies})" + ) + elif self.derank_mode and self.current_trophies <= self.lower_trophies: + self.derank_mode = False + logging.info( + f"[LEAGUE STRATEGY] Lower threshold reached ({self.current_trophies} <= {self.lower_trophies}). " + 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. + 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 + while time.time() - t0 < max_wait_sec: + if not self.running: + return False + frame = self.wm.capture_frame(client_only=True) + if frame is None: + time.sleep(0.02) + continue + + exit_pos = self.find_template_in_roi(frame, "btn_exit", exit_roi, 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) + if leave_pos: + self.click_client_pos_fast(leave_pos[0], leave_pos[1]) + logging.info("[FAST DERANK] Clicked existing 'Leave' confirmation.") + return True + + time.sleep(0.02) + + if not exit_clicked: + logging.warning("[FAST DERANK] Exit button not found within timeout.") + return False + + # Step 2: High-frequency polling for 'Leave' confirmation dialog (check every 20ms) + t_leave = time.time() + while time.time() - t_leave < 4.0: + if not self.running: + return False + frame = self.wm.capture_frame(client_only=True) + if frame is None: + time.sleep(0.02) + continue + + leave_pos = self.find_template_in_roi(frame, "btn_leave", leave_roi, 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.") + time.sleep(0.5) + return True + + time.sleep(0.02) + + logging.warning("[FAST DERANK] Leave button not found after clicking Exit.") + return False + + def handle_lobby(self, frame: np.ndarray): + logging.info("State: TOWER_LOBBY.") + self._result_recorded_for_match = False + # 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 + + logging.info("Searching for 'btn_fight' / 'btn_play'...") + fight_pos = self.find_template_in_roi(frame, "btn_fight", (3000, 750, 3550, 1000)) or \ + self.find_template(frame, "btn_fight") or \ + self.find_template(frame, "btn_play") + if fight_pos: + self.click_client_pos(fight_pos[0], fight_pos[1]) + logging.info("Clicked fight button. Transitioning towards IN_GAME.") + if self.derank_mode or (self.current_wins >= self.max_wins_limit): + self.fast_surrender_pipeline() + else: + time.sleep(1.5) + + def handle_in_game(self, frame: np.ndarray): + should_surrender = self.derank_mode or (self.current_wins >= self.max_wins_limit) + reason = "Forced Derank" if self.derank_mode else f"Win Streak Limit ({self.current_wins}/{self.max_wins_limit})" + + if should_surrender: + logging.warning(f"Surrender condition met [{reason}]. Fast-tracking surrender...") + self.fast_surrender_pipeline(max_wait_sec=5.0) + return + else: + logging.info("Match in progress (Normal Play, Auto-battle active). Waiting for match results...") + + def handle_victory(self, frame: np.ndarray): + if not self._result_recorded_for_match: + self._result_recorded_for_match = True + 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"): + self.click_template(frame, "btn_continue") + time.sleep(1.0) + + def handle_defeat(self, frame: np.ndarray): + if not self._result_recorded_for_match: + self._result_recorded_for_match = True + 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"): + 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") + if open_pos: + logging.debug(f"[NO_FREE_SLOTS] 'Open chest' option detected at {open_pos}.") + + if self.click_template(frame, "btn_remove_chest"): + logging.info("Clicked 'Remove chest' to discard unslotted chest. Returning to lobby...") + time.sleep(1.0) + + def step(self): + """Executes a single FSM iteration.""" + frame = self.wm.capture_frame(client_only=True) + + # Global check: if a promotional / purchase popup is visible, dismiss it first + if self.check_and_dismiss_offer_popup(frame): + return + + detected_state = self.detect_state(frame) + + if detected_state != self.state: + logging.info(f"State changed: {self.state.value} -> {detected_state.value}") + self.state = detected_state + + if self.state == GameState.MAIN_MENU: + self.handle_main_menu(frame) + elif self.state == GameState.TOWER_LOBBY: + self.handle_lobby(frame) + elif self.state == GameState.CHEST_OPEN_SCREEN: + self.handle_chest_open_screen(frame) + elif self.state == GameState.IN_GAME: + self.handle_in_game(frame) + elif self.state == GameState.VICTORY_SCREEN: + self.handle_victory(frame) + elif self.state == GameState.DEFEAT_SCREEN: + self.handle_defeat(frame) + elif self.state == GameState.NO_FREE_SLOTS_SCREEN: + self.handle_no_free_slots(frame) + else: + logging.debug("State: UNKNOWN (waiting or transition in progress)") + + def start(self, max_duration_sec: Optional[int] = None, max_cycles: Optional[int] = None): + """Starts main bot loop with optional duration and battle cycle limits.""" + self.running = True + self.wm.focus() + limits_desc = [] + if max_duration_sec: + limits_desc.append(f"Max time: {max_duration_sec}s") + if max_cycles: + limits_desc.append(f"Max cycles: {max_cycles}") + limit_str = f" ({', '.join(limits_desc)})" if limits_desc else "" + logging.info(f"DirtyLeague Bot loop started{limit_str}.") + + start_time = time.time() + initial_games = self.total_games + + while self.running: + if max_duration_sec and (time.time() - start_time) >= max_duration_sec: + logging.info(f"Execution limit reached: {max_duration_sec}s elapsed. Stopping gracefully...") + break + if max_cycles and (self.total_games - initial_games) >= max_cycles: + logging.info(f"Cycle limit reached: {max_cycles} battle(s) completed. Stopping gracefully...") + break + try: + self.step() + except Exception as e: + logging.error(f"Error during bot step: {e}") + time.sleep(self.poll_interval) + + def stop(self): + """Stops main bot loop.""" + self.running = False + logging.info("DirtyLeague Bot loop stopped.") diff --git a/calibrate_tool.py b/calibrate_tool.py new file mode 100644 index 0000000..95973c2 --- /dev/null +++ b/calibrate_tool.py @@ -0,0 +1,161 @@ +""" +Calibration and ROI helper tool. +Used to: +- Capture current game screen and save snapshots. +- Crop template snippets (buttons, banners) for assets/ +- Test template matching against the active game screen with confidence score. +""" + +import argparse +import json +import os +import sys +import time +import cv2 +import numpy as np +from window_utils import WindowManager + +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") + + +def load_config(): + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + return {} + + +def capture_snapshot(output_path: str = "assets/current_screen.png"): + config = load_config() + target_title = config.get("target_window", {}).get("title", "DirtyLeague") + wm = WindowManager(window_title=target_title) + + hwnd = wm.find_window() + if not hwnd: + print(f"[ERROR] Window '{target_title}' not found!") + return None + + frame = wm.capture_frame(client_only=True) + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + cv2.imwrite(output_path, frame) + h, w = frame.shape[:2] + print(f"[OK] Screenshot saved to: {output_path} (Resolution: {w}x{h})") + return output_path + + +def crop_roi(source_path: str, output_name: str, x: int, y: int, w: int, h: int): + if not os.path.exists(source_path): + print(f"[ERROR] Source file not found: {source_path}") + return + + img = cv2.imread(source_path) + if img is None: + print(f"[ERROR] Could not read image: {source_path}") + return + + roi = img[y:y+h, x:x+w] + if roi.size == 0: + print(f"[ERROR] Crop dimensions resulted in empty image! (x={x}, y={y}, w={w}, h={h})") + return + + out_path = os.path.join("assets", output_name if output_name.endswith(".png") else f"{output_name}.png") + cv2.imwrite(out_path, roi) + print(f"[OK] Cropped ROI saved to: {out_path} ({w}x{h})") + + +def test_match(template_name: str, threshold: float = 0.8): + config = load_config() + target_title = config.get("target_window", {}).get("title", "DirtyLeague") + wm = WindowManager(window_title=target_title) + + template_path = os.path.join("assets", template_name if template_name.endswith(".png") else f"{template_name}.png") + if not os.path.exists(template_path): + print(f"[ERROR] Template not found: {template_path}") + return + + tpl = cv2.imread(template_path) + if tpl is None: + print(f"[ERROR] Failed to load template: {template_path}") + return + + frame = wm.capture_frame(client_only=True) + res = cv2.matchTemplate(frame, tpl, cv2.TM_CCOEFF_NORMED) + min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res) + + th, tw = tpl.shape[:2] + print(f"--- Template Match: {template_name} ---") + print(f"Max confidence: {max_val:.4f} (Threshold: {threshold})") + print(f"Best match client location: ({max_loc[0]}, {max_loc[1]}) -> Center: ({max_loc[0] + tw // 2}, {max_loc[1] + th // 2})") + + if max_val >= threshold: + print("[RESULT] MATCH FOUND! Target is visible.") + else: + print("[RESULT] NOT FOUND (below threshold).") + + +def check_all_assets(): + config = load_config() + target_title = config.get("target_window", {}).get("title", "DirtyLeague") + default_conf = config.get("bot", {}).get("confidence_threshold", 0.8) + wm = WindowManager(window_title=target_title) + + assets_dir = "assets" + if not os.path.exists(assets_dir): + print("[ERROR] assets/ directory does not exist.") + return + + files = [f for f in os.listdir(assets_dir) if f.endswith(".png") and not f.startswith("current_screen")] + if not files: + print("[INFO] No template images found in assets/ yet.") + return + + frame = wm.capture_frame(client_only=True) + print(f"Testing {len(files)} asset templates against current game screen...") + for f in sorted(files): + tpl = cv2.imread(os.path.join(assets_dir, f)) + if tpl is None: + continue + res = cv2.matchTemplate(frame, tpl, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(res) + status = "MATCH" if max_val >= default_conf else "NO MATCH" + print(f" [{status}] {f:<25} confidence: {max_val:.3f} loc: {max_loc}") + + +def main(): + parser = argparse.ArgumentParser(description="Bot UI Calibration & Asset Tool") + subparsers = parser.add_subparsers(dest="command") + + # snapshot command + snap_p = subparsers.add_parser("snapshot", help="Capture current game screen to file") + snap_p.add_argument("-o", "--output", default="assets/current_screen.png", help="Output PNG path") + + # crop command + crop_p = subparsers.add_parser("crop", help="Crop ROI template from an image") + crop_p.add_argument("-s", "--source", default="assets/current_screen.png", help="Source screenshot") + crop_p.add_argument("-n", "--name", required=True, help="Output template name (e.g. btn_play.png)") + crop_p.add_argument("--roi", nargs=4, type=int, required=True, metavar=("X", "Y", "W", "H"), help="Crop region X Y W H") + + # test command + test_p = subparsers.add_parser("test", help="Test match of a single template on live screen") + test_p.add_argument("-n", "--name", required=True, help="Template name in assets/") + test_p.add_argument("-t", "--threshold", type=float, default=0.8, help="Confidence threshold") + + # scan command + subparsers.add_parser("scan", help="Scan all templates in assets/ against current screen") + + args = parser.parse_args() + if args.command == "snapshot": + capture_snapshot(args.output) + elif args.command == "crop": + x, y, w, h = args.roi + crop_roi(args.source, args.name, x, y, w, h) + elif args.command == "test": + test_match(args.name, args.threshold) + elif args.command == "scan": + check_all_assets() + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/concept.md b/concept.md new file mode 100644 index 0000000..c854e9b --- /dev/null +++ b/concept.md @@ -0,0 +1,93 @@ +# ТЕХНИЧЕСКОЕ ЗАДАНИЕ ДЛЯ АВТОНОМНОГО АГЕНТА: UI State Machine Bot для Unity + +## 1. Цель проекта +Разработать легковесного локального Python-бота на базе конечного автомата (FSM), который управляет поведением в игре на Unity (ОС Windows 10). +Бот не должен вычислять игровые ходы. Его задача — контролировать экраны игры, отслеживать статистику матчей, принудительно сдаваться при достижении лимита побед (для контроля MMR/рейтинга) и перезапускать матчи. + +--- + +## 2. Стек технологий +* **Python:** 3.10+ +* **Захват окна / Ввод:** `pyautogui`, `pydirectinput` (для надежного клика в Unity), `win32gui`, `ctypes` +* **Компьютерное зрение:** `opencv-python`, `pillow` +* **Распознавание текста (опционально при динамическом счетчике):** `pytesseract` или `easyocr` +* **Безопасность:** `keyboard` (глобальный аварийный выключатель / Kill Switch) + +--- + +## 3. Архитектура состояний (FSM) + +Бот должен опрашивать состояние игры раз в 0.5–1.0 сек и находиться в одном из состояний: + +1. **TOWER_LOBBY:** + * Считывание трофеев через Windows Native OCR (сравнение с верхним/нижним порогом лиги). + * Проверка прогресса большого сундука с коронами (`read_crown_chest`, счетчик `#/# 👑`). При достижении `5/5` выполняется клик по сундуку для перехода на экран открытия. + * Сканирование 4 слотов нижних сундуков (`BOTTOM_CHESTS`). Если есть слот `OPEN` -> клик по слоту для открытия и забора наград. + * Если готовых сундуков нет -> клик по кнопке `FIGHT`. Переход в `IN_GAME`. + +2. **IN_GAME (Экран боя):** + * **FORCED DERANK (Сверхбыстрая капитуляция)**: + - Сразу после клика `FIGHT` в лобби запускается высокочастотный конвейер `fast_surrender_pipeline` (опрос ROI каждые 20 мс). + - В первую же миллисекунду после завершения загрузки боя нажимается `Exit` (`btn_exit.png`) через `click_client_pos_fast` (без задержек). + - Мгновенно опрашивается диалог подтверждения `Leave` (`btn_leave.png`) и нажимается подтверждение. + - Полный цикл сдачи занимает **< 0.15–0.2 сек** от появления экрана боя, исключая случайную победу автобоя. + * **NORMAL PLAY**: + - Ожидание естественного исхода боя (автобой побеждает или терпит поражение). + - Бот не вмешивается и ожидает `VICTORY_SCREEN` или `DEFEAT_SCREEN`. + +3. **VICTORY_SCREEN:** + * Зафиксировать: `current_wins += 1`, `total_games += 1`. + * Записать лог: `[WIN] Серия побед: {current_wins}/{max_wins_limit}`. + * Нажать кнопку `Collect` (`btn_collect_victory.png`). + * Если были свободные ячейки (`VACANT`) -> переход в `TOWER_LOBBY`. + * Если свободных ячеек не было -> переход в `NO_FREE_SLOTS_SCREEN`. + +4. **DEFEAT_SCREEN:** + * Зафиксировать: `current_wins = 0` (сброс серии), `total_games += 1`. + * Записать лог: `[LOSS] Серия сброшена. Всего игр: {total_games}`. + * Нажать кнопку `Ok` (`btn_ok.png`). Переход в `TOWER_LOBBY`. + +5. **CHEST_OPEN_SCREEN (Экран открытия сундуков - нижних и сундука побед с коронами):** + * Срабатывает при клике по готовому сундуку (`OPEN`) или сундуку с коронами (`5/5 👑`). + * Шаг 1: Нажать «Turn all cards over» (`btn_turn_all_cards.png`). Ожидание анимации переворота карт. + * Шаг 2: Нажать «Collect» (`btn_collect.png`). Забор наград и возврат в `TOWER_LOBBY`. + +6. **NO_FREE_SLOTS_SCREEN:** + * Появляется после нажатия `Collect` при отсутствии свободных ячеек сундуков ("You have no slot available for this chest"). + * Опция `Open chest` (`btn_open_chest.png`) обнаруживается ботом (задел на будущее). + * Нажимается кнопка `Remove chest` (`btn_remove_chest.png`) для сброса лишнего сундука и возврата в `TOWER_LOBBY`. + +7. **GLOBAL MODAL / SPECIAL OFFER (Заглушка):** + * Всплывающее рекламное предложение покупки, появляющееся рандомно или после наград. + * Проверяется глобально на каждом шаге цикла FSM (`check_and_dismiss_offer_popup`). + * При обнаружении нажимается кнопка закрытия `X` (`btn_close.png`). + +--- + +## 4. Структура проекта + +```text +game_bot/ +├── assets/ +│ ├── btn_fight.png # Кнопка старта боя в лобби +│ ├── btn_exit.png # Кнопка выхода во время боя +│ ├── btn_leave.png # Кнопка подтверждения выхода (Leave) +│ ├── btn_ok.png # Кнопка OK на экране поражения +│ ├── btn_close.png # Кнопка X закрытия рекламных предложений +│ ├── text_defeat.png # Маркер экрана поражения +│ ├── banner_victory.png # Маркер экрана победы (баннер) +│ ├── text_victory.png # Маркер экрана победы (текст) +│ ├── btn_collect_victory.png # Кнопка Collect на экране победы +│ ├── btn_remove_chest.png # Кнопка Remove chest при отсутствии слотов +│ ├── btn_open_chest.png # Кнопка Open chest при отсутствии слотов +│ ├── status_open.png # Маркер готового к открытию сундука +│ ├── status_vacant.png # Маркер пустого слота сундука +│ ├── btn_turn_all_cards.png# Кнопка открытия всех карт в сундуке +│ └── btn_collect.png # Кнопка сбора наград сундука +├── config.json # Конфигурация лимитов и задержек +├── window_utils.py # Фокусировка и нормализация окна Unity +├── ocr_utils.py # OCR чтение трофеев (рейтинга) +├── bot_core.py # Реализация FSM, логики кликов и проверки заглушек +├── calibrate_tool.py # Утилита для нарезки шаблонов и проверки ROI +└── run.py # Точка входа с Kill Switch +``` \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..55660e4 --- /dev/null +++ b/config.json @@ -0,0 +1,21 @@ +{ + "target_window": { + "title": "DirtyLeague", + "process_name": "DirtyLeague.exe" + }, + "bot": { + "max_wins_limit": 3, + "poll_interval_sec": 0.8, + "confidence_threshold": 0.8, + "action_delay_sec": 0.5, + "kill_switch_key": "q" + }, + "league_retention": { + "upper_trophy_threshold": 2125, + "lower_trophy_threshold": 125, + "points_per_win": 50, + "points_per_loss": 50 + }, + "assets_dir": "assets", + "log_level": "INFO" +} diff --git a/ocr_utils.py b/ocr_utils.py new file mode 100644 index 0000000..fe667e1 --- /dev/null +++ b/ocr_utils.py @@ -0,0 +1,159 @@ +""" +OCR utility module using Windows Native OCR (Windows.Media.Ocr via winsdk). +Fast, reliable, offline, and requires no external binaries. +""" + +import asyncio +import os +import re +from typing import Optional, Tuple +import cv2 +import numpy as np + +import winsdk.windows.globalization as glob +import winsdk.windows.graphics.imaging as imaging +import winsdk.windows.media.ocr as win_ocr +import winsdk.windows.storage.streams as streams + + +class OcrReader: + def __init__(self, lang_tag: str = "en-US"): + self.lang_tag = lang_tag + self._engine = None + + def _get_engine(self): + if self._engine is None: + lang = glob.Language(self.lang_tag) + self._engine = win_ocr.OcrEngine.try_create_from_language(lang) + if not self._engine: + self._engine = win_ocr.OcrEngine.try_create_from_user_profile_languages() + return self._engine + + async def _recognize_async(self, bgr_image: np.ndarray) -> str: + engine = self._get_engine() + bgra = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2BGRA) + success, encoded = cv2.imencode(".png", bgra) + if not success: + return "" + + bytes_data = encoded.tobytes() + stream = streams.InMemoryRandomAccessStream() + writer = streams.DataWriter(stream) + writer.write_bytes(bytes_data) + await writer.store_async() + await writer.flush_async() + stream.seek(0) + + decoder = await imaging.BitmapDecoder.create_async(stream) + software_bitmap = await decoder.get_software_bitmap_async() + result = await engine.recognize_async(software_bitmap) + return result.text.strip() + + def read_text(self, bgr_image: np.ndarray) -> str: + """Synchronously recognizes text from a BGR numpy image.""" + try: + return asyncio.run(self._recognize_async(bgr_image)) + except Exception: + # Fallback for environments with active event loop + loop = asyncio.new_event_loop() + return loop.run_until_complete(self._recognize_async(bgr_image)) + + def read_trophies(self, frame: np.ndarray) -> Optional[Tuple[int, int]]: + """ + Extracts trophies counter (current, max) from TOWER_LOBBY screen. + Expected format: '2125/2300' -> returns (2125, 2300). + """ + # 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: + return None + + trophy_crop = frame[540:650, 3130:3500] + text = self.read_text(trophy_crop) + + # Clean up text and parse + match = re.search(r"(\d+)\s*[/\\|]\s*(\d+)", text) + if match: + current_val = int(match.group(1)) + max_val = int(match.group(2)) + return current_val, max_val + + return None + + def read_crown_chest(self, frame: np.ndarray) -> Optional[Tuple[int, int]]: + """ + Extracts crown chest counter (current, max) from TOWER_LOBBY screen. + Expected format: '3/5' or '5/5' -> returns (current, 5). + """ + fh, fw = frame.shape[:2] + if fh < 1000 or fw < 2000: + return None + + # 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") + 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 + + # 2. Template check for 3/5 + tpl_3_path = os.path.join(os.path.dirname(__file__), "assets", "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 + + # 3. Fallback to Windows native OCR + badge_crop = frame[1380:1470, 740:1000] + w = 1000 - 740 + 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) + + match = re.search(r"(\d+)\s*[/\\|]\s*(\d+)", text) + if match: + current_val = int(match.group(1)) + max_val = int(match.group(2)) + return current_val, max_val + + return None + + def read_timer(self, timer_crop: np.ndarray) -> Optional[int]: + """ + Reads timer string (e.g. '51:53' or '1:12' or '01:23:45') and converts to total seconds. + """ + text = self.read_text(timer_crop) + parts = re.findall(r"\d+", text) + if len(parts) == 2: + minutes, seconds = int(parts[0]), int(parts[1]) + return minutes * 60 + seconds + elif len(parts) == 3: + hours, minutes, seconds = int(parts[0]), int(parts[1]), int(parts[2]) + return hours * 3600 + minutes * 60 + seconds + return None + + +# Global singleton instance for easy access +ocr_reader = OcrReader() + + +if __name__ == "__main__": + test_img = cv2.imread("assets/trophy_numbers.png") + text = ocr_reader.read_text(test_img) + print("Raw OCR Text:", text) + + full_frame = cv2.imread("assets/trophies_check_snapshot.png") + trophies = ocr_reader.read_trophies(full_frame) + print("Parsed Trophies as integers:", trophies) + if trophies: + current, maximum = trophies + print(f"Current: {current} (type: {type(current)}), Max: {maximum} (type: {type(maximum)})") + print(f"Trophies until goal: {maximum - current}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b300df --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +opencv-python>=4.7.0 +pillow>=10.0.0 +numpy>=1.24.0 +pyautogui>=0.9.54 +pydirectinput>=1.0.4 +pywin32>=306 +keyboard>=0.13.5 +winsdk>=1.0.0b10 diff --git a/run.py b/run.py new file mode 100644 index 0000000..81d5356 --- /dev/null +++ b/run.py @@ -0,0 +1,92 @@ +""" +Entry point for DirtyLeague Bot. +Handles configuration loading, logging initialization, and global Kill Switch. +""" + +import json +import logging +import os +import sys +import threading +import time +import keyboard + +CONFIG_FILE = "config.json" + + +def setup_logging(log_level_str: str = "INFO"): + os.makedirs("logs", exist_ok=True) + log_file = os.path.join("logs", f"bot_{time.strftime('%Y%m%d_%H%M%S')}.log") + + # Ensure stdout handles unicode properly on Windows + if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + level = getattr(logging, log_level_str.upper(), logging.INFO) + + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(log_file, encoding="utf-8"), + logging.StreamHandler(sys.stdout) + ] + ) + logging.info(f"Logging initialized. Log file: {log_file}") + + +def load_config(): + if not os.path.exists(CONFIG_FILE): + raise FileNotFoundError(f"Configuration file '{CONFIG_FILE}' not found.") + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="DirtyLeague Autonomous FSM Bot") + parser.add_argument("--duration", type=int, default=None, help="Stop after N seconds") + parser.add_argument("--cycles", type=int, default=None, help="Stop after N battles/cycles") + args = parser.parse_args() + + config = load_config() + bot_conf = config.get("bot", {}) + setup_logging(config.get("log_level", "INFO")) + + kill_key = bot_conf.get("kill_switch_key", "q") + logging.info(f"Starting DirtyLeague Bot. Press '{kill_key.upper()}' at ANY time for Emergency Stop (Kill Switch).") + + from bot_core import DirtyLeagueBot + + bot = DirtyLeagueBot(config=config) + stop_event = threading.Event() + + def kill_switch_listener(): + while not stop_event.is_set(): + if keyboard.is_pressed(kill_key): + logging.warning(f"!!! KILL SWITCH '{kill_key.upper()}' PRESSED! Stopping bot immediately... !!!") + bot.stop() + stop_event.set() + break + time.sleep(0.05) + + listener_thread = threading.Thread(target=kill_switch_listener, daemon=True) + listener_thread.start() + + try: + bot.start(max_duration_sec=args.duration, max_cycles=args.cycles) + except KeyboardInterrupt: + logging.info("Bot interrupted by User (Ctrl+C).") + except Exception as e: + logging.exception(f"Unexpected error in bot execution: {e}") + finally: + stop_event.set() + bot.stop() + logging.info("Bot stopped successfully.") + + +if __name__ == "__main__": + main() diff --git a/start_bot.bat b/start_bot.bat new file mode 100644 index 0000000..3767333 --- /dev/null +++ b/start_bot.bat @@ -0,0 +1,20 @@ +@echo off +chcp 65001 > nul +echo ======================================================== +echo DirtyLeague Autonomous Bot +echo ======================================================== +echo. +echo Press 'Q' at ANY time for Emergency Stop (Kill Switch). +echo. + +if exist ".venv\Scripts\python.exe" ( + ".venv\Scripts\python.exe" run.py %* +) else ( + python run.py %* +) + +if %ERRORLEVEL% neq 0 ( + echo. + echo Bot stopped with exit code %ERRORLEVEL%. + pause +) diff --git a/test_chest_flow.py b/test_chest_flow.py new file mode 100644 index 0000000..08f66ad --- /dev/null +++ b/test_chest_flow.py @@ -0,0 +1,142 @@ +""" +Verification script for complete chest opening flow: +1. Check which chests are ready (OPEN). +2. Open the first ready chest. +3. Handle CHEST_OPEN_SCREEN (Turn all cards over -> Collect). +4. Return to TOWER_LOBBY and re-scan slots. +""" + +import json +import logging +import sys +import time +from bot_core import DirtyLeagueBot, GameState + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)] +) + + +def run_chest_flow_test(): + with open("config.json", "r", encoding="utf-8") as f: + config = json.load(f) + + bot = DirtyLeagueBot(config) + logging.info("--- Starting Chest Flow Test ---") + + # Step 1: Ensure we are in TOWER_LOBBY + frame = bot.wm.capture_frame(client_only=True) + state = bot.detect_state(frame) + logging.info(f"Current State: {state.value}") + if state != GameState.TOWER_LOBBY: + logging.error(f"Expected TOWER_LOBBY, but detected {state.value}. Aborting test.") + return False + + # Step 2: Scan chests + chests = bot.scan_bottom_chests(frame) + logging.info("Initial Chest Slots status:") + for slot_id, info in chests.items(): + logging.info(f" Slot #{slot_id}: {info['status']}") + + # Find first OPEN chest + target_slot = None + for slot_id, info in sorted(chests.items()): + if info["status"] == "OPEN": + target_slot = slot_id + break + + if not target_slot: + logging.warning("No chests with status 'OPEN' found to test.") + return False + + logging.info(f"Targeting first ready chest: Slot #{target_slot} at {chests[target_slot]['click_pos']}") + + # Step 3: Click the target chest slot + bot.click_client_pos(*chests[target_slot]["click_pos"]) + logging.info("Clicked chest slot. Waiting for CHEST_OPEN_SCREEN...") + + # Step 4: Wait for CHEST_OPEN_SCREEN + opened = False + start_time = time.time() + while time.time() - start_time < 15.0: + time.sleep(0.5) + current_frame = bot.wm.capture_frame(client_only=True) + current_state = bot.detect_state(current_frame) + + if current_state == GameState.CHEST_OPEN_SCREEN: + logging.info(f"Entered CHEST_OPEN_SCREEN after {time.time() - start_time:.1f}s.") + opened = True + break + + if not opened: + logging.error("Failed to detect CHEST_OPEN_SCREEN within timeout.") + return False + + # Step 5: Handle Turn all cards over + logging.info("Looking for 'Turn all cards over' button...") + turn_clicked = False + sub_start = time.time() + while time.time() - sub_start < 10.0: + current_frame = bot.wm.capture_frame(client_only=True) + if bot.click_template(current_frame, "btn_turn_all_cards"): + logging.info("Clicked 'Turn all cards over' successfully!") + turn_clicked = True + break + elif bot.find_template(current_frame, "btn_collect"): + # Cards might already be turned + turn_clicked = True + break + time.sleep(0.5) + + # Step 6: Wait for Collect button and click it + logging.info("Waiting for 'Collect' button to appear...") + collect_clicked = False + collect_start = time.time() + while time.time() - collect_start < 12.0: + current_frame = bot.wm.capture_frame(client_only=True) + if bot.click_template(current_frame, "btn_collect"): + logging.info("Clicked 'Collect' successfully! Rewards gathered.") + collect_clicked = True + break + time.sleep(0.5) + + if not collect_clicked: + logging.error("Failed to click 'Collect' button.") + return False + + # Step 7: Dismiss offer popup if any + time.sleep(1.0) + after_frame = bot.wm.capture_frame(client_only=True) + bot.check_and_dismiss_offer_popup(after_frame) + + # Step 8: Wait to return to TOWER_LOBBY and re-scan + logging.info("Waiting to return to TOWER_LOBBY...") + returned = False + ret_start = time.time() + while time.time() - ret_start < 10.0: + time.sleep(0.5) + lobby_frame = bot.wm.capture_frame(client_only=True) + lobby_state = bot.detect_state(lobby_frame) + if lobby_state == GameState.TOWER_LOBBY: + logging.info("Successfully returned to TOWER_LOBBY!") + returned = True + break + + if not returned: + logging.warning("Did not detect TOWER_LOBBY yet, capturing current state...") + lobby_frame = bot.wm.capture_frame(client_only=True) + + # Re-scan chests to verify the opened slot changed + final_chests = bot.scan_bottom_chests(lobby_frame) + logging.info("Final Chest Slots status after test:") + for slot_id, info in final_chests.items(): + logging.info(f" Slot #{slot_id}: {info['status']}") + + logging.info("--- Chest Flow Test COMPLETED SUCCESSFULLY! ---") + return True + + +if __name__ == "__main__": + run_chest_flow_test() diff --git a/test_full_loop.py b/test_full_loop.py new file mode 100644 index 0000000..400fcbb --- /dev/null +++ b/test_full_loop.py @@ -0,0 +1,105 @@ +""" +Controlled single-cycle integration test: +Runs 1 full cycle starting from TOWER_LOBBY: +1. Detect TOWER_LOBBY +2. Read trophies and chests +3. Enter Fight +4. Handle IN_GAME until outcome (Victory / Defeat / Surrender) +5. Handle Result screen (Collect / OK / Remove chest if no slots) +6. Verify clean return to TOWER_LOBBY +""" + +import json +import logging +import sys +import time +from bot_core import DirtyLeagueBot, GameState + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)] +) + + +def run_single_cycle_test(timeout_sec: int = 180): + with open("config.json", "r", encoding="utf-8") as f: + config = json.load(f) + + bot = DirtyLeagueBot(config) + logging.info("=== Starting Controlled Full Cycle Test ===") + logging.info(f"Timeout: {timeout_sec}s. Press Ctrl+C to abort.") + + start_time = time.time() + initial_lobby_confirmed = False + in_game_reached = False + result_handled = False + cycle_complete = False + + while time.time() - start_time < timeout_sec: + frame = bot.wm.capture_frame(client_only=True) + + # 1. Global offer popup check + if bot.check_and_dismiss_offer_popup(frame): + time.sleep(1.0) + continue + + state = bot.detect_state(frame) + + if state != bot.state: + logging.info(f"[FSM TRANSITION] {bot.state.value} -> {state.value}") + bot.state = state + + if state == GameState.TOWER_LOBBY: + if not initial_lobby_confirmed: + logging.info("[STEP 1] Confirmed TOWER_LOBBY start.") + initial_lobby_confirmed = True + bot.handle_lobby(frame) + elif in_game_reached and result_handled: + logging.info("[SUCCESS] Successfully completed full cycle and returned to TOWER_LOBBY!") + cycle_complete = True + break + else: + # If we are still in lobby before entering game, try handle_lobby again + bot.handle_lobby(frame) + + elif state == GameState.IN_GAME: + in_game_reached = True + bot.handle_in_game(frame) + + elif state == GameState.VICTORY_SCREEN: + logging.info("[STEP] Handling VICTORY_SCREEN...") + bot.handle_victory(frame) + result_handled = True + + elif state == GameState.DEFEAT_SCREEN: + logging.info("[STEP] Handling DEFEAT_SCREEN...") + bot.handle_defeat(frame) + result_handled = True + + elif state == GameState.NO_FREE_SLOTS_SCREEN: + logging.info("[STEP] Handling NO_FREE_SLOTS_SCREEN...") + bot.handle_no_free_slots(frame) + result_handled = True + + elif state == GameState.CHEST_OPEN_SCREEN: + logging.info("[STEP] Handling CHEST_OPEN_SCREEN...") + bot.handle_chest_open_screen(frame) + + elif state == GameState.MAIN_MENU: + logging.info("[STEP] Handling MAIN_MENU...") + bot.handle_main_menu(frame) + + time.sleep(bot.poll_interval) + + elapsed = time.time() - start_time + if cycle_complete: + logging.info(f"=== FULL CYCLE TEST PASSED in {elapsed:.1f}s ===") + return True + else: + logging.warning(f"=== TEST TIMEOUT or INTERRUPTED after {elapsed:.1f}s ===") + return False + + +if __name__ == "__main__": + run_single_cycle_test() diff --git a/window_utils.py b/window_utils.py new file mode 100644 index 0000000..347a08d --- /dev/null +++ b/window_utils.py @@ -0,0 +1,207 @@ +""" +Utility module for window management, screen capture, and coordinate translation +for the Unity game bot. +""" + +import ctypes +from ctypes import wintypes +import time +from typing import Optional, Tuple +import numpy as np +from PIL import Image + +user32 = ctypes.windll.user32 +gdi32 = ctypes.windll.gdi32 + +# Enable Per-Monitor DPI Awareness V2 +try: + shcore = ctypes.windll.shcore + shcore.SetProcessDpiAwareness(2) +except Exception: + pass + +DESKTOP_ALL_ACCESS = 0x01FF +PW_RENDERFULLCONTENT = 2 +SRCCOPY = 0x00CC0020 +SW_RESTORE = 9 +SW_SHOW = 5 + + +def ensure_interactive_desktop(): + """ + Ensures the current thread is attached to the interactive 'Default' desktop. + Required when running under background/isolated sandbox sessions. + """ + try: + h_desk = user32.OpenDesktopW("Default", 0, False, DESKTOP_ALL_ACCESS) + if h_desk: + user32.SetThreadDesktop(h_desk) + except Exception: + pass + + +ensure_interactive_desktop() + + +class WindowManager: + """Manages window discovery, activation, and capture.""" + + def __init__(self, window_title: str = "DirtyLeague", process_name: str = "DirtyLeague.exe"): + self.window_title = window_title + self.process_name = process_name + self._hwnd: Optional[int] = None + + def find_window(self) -> Optional[int]: + """Finds the target game window handle.""" + ensure_interactive_desktop() + + # 1. Search by exact window title + hwnd = user32.FindWindowW(None, self.window_title) + if hwnd and user32.IsWindow(hwnd): + self._hwnd = hwnd + return hwnd + + # 2. Search by partial title or process + found_hwnds = [] + WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + + def _enum_callback(h, _): + if user32.IsWindowVisible(h): + length = user32.GetWindowTextLengthW(h) + if length > 0: + buff = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(h, buff, length + 1) + title = buff.value + if self.window_title.lower() in title.lower(): + found_hwnds.append(h) + return True + + h_desk = user32.OpenDesktopW("Default", 0, False, DESKTOP_ALL_ACCESS) + if h_desk: + user32.EnumDesktopWindows(h_desk, WNDENUMPROC(_enum_callback), 0) + user32.CloseDesktop(h_desk) + else: + user32.EnumWindows(WNDENUMPROC(_enum_callback), 0) + + if found_hwnds: + self._hwnd = found_hwnds[0] + return self._hwnd + + return None + + @property + def hwnd(self) -> int: + """Returns active hwnd or searches for it.""" + if not self._hwnd or not user32.IsWindow(self._hwnd): + hwnd = self.find_window() + if not hwnd: + raise RuntimeError(f"Window '{self.window_title}' not found. Ensure the game is running.") + return self._hwnd + + def focus(self) -> bool: + """Brings the window to the foreground and unminimizes if needed.""" + hwnd = self.hwnd + if user32.IsIconic(hwnd): + user32.ShowWindow(hwnd, SW_RESTORE) + time.sleep(0.2) + + user32.ShowWindow(hwnd, SW_SHOW) + user32.SetForegroundWindow(hwnd) + time.sleep(0.1) + return True + + def get_window_rect(self) -> Tuple[int, int, int, int]: + """Returns (left, top, width, height) of the entire window.""" + rect = wintypes.RECT() + user32.GetWindowRect(self.hwnd, ctypes.byref(rect)) + return rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top + + def get_client_rect(self) -> Tuple[int, int, int, int]: + """Returns (client_left, client_top, client_width, client_height) in screen coordinates.""" + hwnd = self.hwnd + crect = wintypes.RECT() + user32.GetClientRect(hwnd, ctypes.byref(crect)) + width = crect.right - crect.left + height = crect.bottom - crect.top + + pt = wintypes.POINT(0, 0) + user32.ClientToScreen(hwnd, ctypes.byref(pt)) + return pt.x, pt.y, width, height + + def capture_frame(self, client_only: bool = True) -> np.ndarray: + """ + Captures the current window content and returns a BGR NumPy array (OpenCV format). + Uses direct Desktop BitBlt for highest fidelity and hardware acceleration support. + """ + ensure_interactive_desktop() + hwnd = self.hwnd + + c_left, c_top, c_width, c_height = self.get_client_rect() + if c_width <= 0 or c_height <= 0: + raise RuntimeError(f"Invalid client dimensions: {c_width}x{c_height}") + + if client_only: + capture_x, capture_y, capture_w, capture_h = c_left, c_top, c_width, c_height + hdc_source = user32.GetDC(0) # Desktop DC + else: + w_left, w_top, w_width, w_height = self.get_window_rect() + capture_x, capture_y, capture_w, capture_h = w_left, w_top, w_width, w_height + hdc_source = user32.GetDC(0) + + mem_dc = gdi32.CreateCompatibleDC(hdc_source) + save_bitmap = gdi32.CreateCompatibleBitmap(hdc_source, capture_w, capture_h) + gdi32.SelectObject(mem_dc, save_bitmap) + + gdi32.BitBlt(mem_dc, 0, 0, capture_w, capture_h, hdc_source, capture_x, capture_y, SRCCOPY) + + class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [ + ('biSize', wintypes.DWORD), + ('biWidth', wintypes.LONG), + ('biHeight', wintypes.LONG), + ('biPlanes', wintypes.WORD), + ('biBitCount', wintypes.WORD), + ('biCompression', wintypes.DWORD), + ('biSizeImage', wintypes.DWORD), + ('biXPelsPerMeter', wintypes.LONG), + ('biYPelsPerMeter', wintypes.LONG), + ('biClrUsed', wintypes.DWORD), + ('biClrImportant', wintypes.DWORD) + ] + + bmi = BITMAPINFOHEADER() + bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.biWidth = capture_w + bmi.biHeight = -capture_h + bmi.biPlanes = 1 + bmi.biBitCount = 32 + bmi.biCompression = 0 + + buffer_len = capture_w * capture_h * 4 + buf = (ctypes.c_ubyte * buffer_len)() + gdi32.GetDIBits(mem_dc, save_bitmap, 0, capture_h, ctypes.byref(buf), ctypes.byref(bmi), 0) + + gdi32.DeleteObject(save_bitmap) + gdi32.DeleteDC(mem_dc) + user32.ReleaseDC(0, hdc_source) + + raw_arr = np.ctypeslib.as_array(buf).reshape((capture_h, capture_w, 4)) + return raw_arr[:, :, :3].copy() + + def client_to_screen(self, x: int, y: int) -> Tuple[int, int]: + """Translates client coordinates (x, y) to absolute desktop screen coordinates.""" + c_left, c_top, _, _ = self.get_client_rect() + return c_left + x, c_top + y + + +if __name__ == "__main__": + wm = WindowManager() + hwnd = wm.find_window() + print(f"Found Window HWND: {hwnd}") + if hwnd: + rect = wm.get_window_rect() + crect = wm.get_client_rect() + print(f"Window Rect: {rect}") + print(f"Client Rect: {crect}") + frame = wm.capture_frame(client_only=True) + print(f"Captured frame shape: {frame.shape}")