feat: implement resolution scaling and relative percentage coordinates

This commit is contained in:
VolandSZ
2026-09-08 22:33:48 +03:00
parent 5777748abe
commit 9b71e20e8f
7 changed files with 360 additions and 166 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -1,6 +1,7 @@
""" """
Core FSM Bot engine for DirtyLeague. Core FSM Bot engine for DirtyLeague.
Manages screen recognition, game state transitions, and automated inputs. Manages screen recognition, game state transitions, and automated inputs.
Supports resolution-independent relative coordinates and adaptive template scaling.
""" """
from enum import Enum from enum import Enum
@ -10,7 +11,6 @@ import time
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
import cv2 import cv2
import numpy as np import numpy as np
import pydirectinput
from window_utils import WindowManager from window_utils import WindowManager
from ocr_utils import ocr_reader from ocr_utils import ocr_reader
@ -27,6 +27,25 @@ class GameState(Enum):
class DirtyLeagueBot: 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): def __init__(self, config: dict):
self.config = config self.config = config
self.bot_conf = config.get("bot", {}) self.bot_conf = config.get("bot", {})
@ -56,11 +75,20 @@ class DirtyLeagueBot:
window_title=self.target_conf.get("title", "DirtyLeague"), window_title=self.target_conf.get("title", "DirtyLeague"),
process_name=self.target_conf.get("process_name", "DirtyLeague.exe") 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() 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): 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): if not os.path.exists(self.assets_dir):
return return
for file_name in os.listdir(self.assets_dir): for file_name in os.listdir(self.assets_dir):
@ -69,27 +97,52 @@ class DirtyLeagueBot:
path = os.path.join(self.assets_dir, file_name) path = os.path.join(self.assets_dir, file_name)
img = cv2.imread(path) img = cv2.imread(path)
if img is not None: if img is not None:
self.templates[name_without_ext] = img self.base_templates[name_without_ext] = img
logging.info(f"Loaded {len(self.templates)} template(s) from '{self.assets_dir}': {list(self.templates.keys())}") 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. Retrieves template scaled for the given resolution scale factor (fw / BASE_DESIGN_WIDTH).
Returns client center coordinates (center_x, center_y) if matched above threshold, else None. Caches scaled results so resizing only happens on window resolution changes.
""" """
if template_name not in self.templates: if template_name not in self.base_templates:
# Try reloading in case new template was added
path = os.path.join(self.assets_dir, f"{template_name}.png") path = os.path.join(self.assets_dir, f"{template_name}.png")
if os.path.exists(path): if os.path.exists(path):
img = cv2.imread(path) img = cv2.imread(path)
if img is not None: if img is not None:
self.templates[template_name] = img self.base_templates[template_name] = img
if template_name not in self.templates: if template_name not in self.base_templates:
return None return None
tpl = self.templates[template_name] # If resolution scale changed, rebuild scaled template cache
th, tw = tpl.shape[:2] 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] 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: if th > fh or tw > fw:
return None return None
@ -105,28 +158,32 @@ class DirtyLeagueBot:
return None return None
def find_template_in_roi( def find_template_in_rel_roi(
self, self,
frame: np.ndarray, frame: np.ndarray,
template_name: str, template_name: str,
roi: Tuple[int, int, int, int], rel_roi: Tuple[float, float, float, float],
threshold: Optional[float] = None threshold: Optional[float] = None
) -> Optional[Tuple[int, int]]: ) -> 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. 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] 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 scale = fw / float(self.BASE_DESIGN_WIDTH)
x1, y1 = max(0, x1), max(0, y1) tpl = self.get_scaled_template(template_name, scale)
x2, y2 = min(fw, x2), min(fh, y2) if tpl is None:
if x2 - x1 < tw or y2 - y1 < th: return None
th, tw = tpl.shape[:2]
if (x2 - x1) < tw or (y2 - y1) < th:
return None return None
crop = frame[y1:y2, x1:x2] crop = frame[y1:y2, x1:x2]
@ -140,6 +197,20 @@ class DirtyLeagueBot:
return cx, cy return cx, cy
return None 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): def click_client_pos(self, x: int, y: int):
"""Translates client coordinates to screen and performs a click via Win32 API.""" """Translates client coordinates to screen and performs a click via Win32 API."""
screen_x, screen_y = self.wm.client_to_screen(x, y) screen_x, screen_y = self.wm.client_to_screen(x, y)
@ -175,29 +246,29 @@ class DirtyLeagueBot:
return False return False
def detect_state(self, frame: np.ndarray) -> GameState: def detect_state(self, frame: np.ndarray) -> GameState:
"""Determines the current game screen based on visible templates (accelerated with ROIs).""" """Determines current game screen based on visible templates (accelerated with relative ROIs)."""
# 1. Ultra-fast ROI checks (~3-5ms each instead of ~420ms full-frame) # 1. Ultra-fast relative ROI checks (~3-5ms each on any resolution)
if self.find_template_in_roi(frame, "btn_remove_chest", (1350, 1680, 1950, 1850)) or \ if self.find_template_in_rel_roi(frame, "btn_remove_chest", self.REL_ROI_NO_SLOTS_REMOVE) or \
self.find_template_in_roi(frame, "btn_open_chest", (1850, 1680, 2350, 1850)): self.find_template_in_rel_roi(frame, "btn_open_chest", self.REL_ROI_NO_SLOTS_OPEN):
return GameState.NO_FREE_SLOTS_SCREEN return GameState.NO_FREE_SLOTS_SCREEN
if self.find_template_in_roi(frame, "btn_ok", (900, 1480, 1450, 1700)) or \ if self.find_template_in_rel_roi(frame, "btn_ok", self.REL_ROI_DEFEAT_OK) or \
self.find_template_in_roi(frame, "text_defeat", (500, 1400, 1200, 1700), threshold=0.65): self.find_template_in_rel_roi(frame, "text_defeat", self.REL_ROI_DEFEAT_TEXT, threshold=0.65):
return GameState.DEFEAT_SCREEN return GameState.DEFEAT_SCREEN
if self.find_template_in_roi(frame, "btn_exit", (1600, 1900, 1920, 2020)) or \ if self.find_template_in_rel_roi(frame, "btn_exit", self.REL_ROI_EXIT) or \
self.find_template_in_roi(frame, "btn_leave", (1900, 1150, 2400, 1400)): self.find_template_in_rel_roi(frame, "btn_leave", self.REL_ROI_LEAVE):
return GameState.IN_GAME 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 return GameState.TOWER_LOBBY
if self.find_template_in_roi(frame, "btn_turn_all_cards", (1550, 1350, 2250, 1550)) or \ if self.find_template_in_rel_roi(frame, "btn_turn_all_cards", self.REL_ROI_CHEST_TURN_CARDS) or \
self.find_template_in_roi(frame, "btn_collect", (1550, 1350, 2250, 1550), threshold=0.65): self.find_template_in_rel_roi(frame, "btn_collect", self.REL_ROI_CHEST_COLLECT, threshold=0.65):
return GameState.CHEST_OPEN_SCREEN return GameState.CHEST_OPEN_SCREEN
if self.find_template_in_roi(frame, "btn_collect_victory", (750, 1550, 1200, 1750)) or \ if self.find_template_in_rel_roi(frame, "btn_collect_victory", self.REL_ROI_VICTORY_COLLECT) or \
self.find_template_in_roi(frame, "banner_victory", (400, 300, 1600, 700), threshold=0.65): self.find_template_in_rel_roi(frame, "banner_victory", self.REL_ROI_VICTORY_BANNER, threshold=0.65):
return GameState.VICTORY_SCREEN return GameState.VICTORY_SCREEN
# 2. Full-frame fallback checks if ROI didn't trigger: # 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: def scan_bottom_chests(self, frame: np.ndarray) -> dict:
""" """
Scans chest slots 1..4 in the bottom bar of TOWER_LOBBY. Scans chest slots 1..4 in the bottom bar of TOWER_LOBBY using relative coordinates.
Grid calibrated: slot 1 starts at x=525, slot pitch = 213px. Supports any resolution dynamically.
Returns dict of slot_id -> {"status": "OPEN"|"TIMER"|"VACANT", "click_pos": (cx, cy)}
""" """
results = {} fh, fw = frame.shape[:2]
slot_pitch = 213 rel_base_x = 0.1367
base_x = 525 rel_pitch_x = 0.0555
y = 1850 rel_y = 0.9024
w = 210 rel_w = 0.0547
h = 70 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): for i in range(4):
slot_id = i + 1 slot_id = i + 1
sx = base_x + i * slot_pitch sx = int((rel_base_x + i * rel_pitch_x) * fw)
status_crop = frame[y:y + h, sx:sx + w] 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
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" 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"
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] = { results[slot_id] = {
"status": status, "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 return results
def check_and_dismiss_offer_popup(self, frame: np.ndarray) -> bool: def check_and_dismiss_offer_popup(self, frame: np.ndarray) -> bool:
""" """
Detects and dismisses promotional or purchase offer popups ('X' close button) Detects and dismisses promotional or purchase offer popups ('X' close button).
that may appear randomly or after claiming chest rewards/returning to lobby. Uses relative ROI for the top-right corner across all resolutions.
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"]: for tpl_name in ["btn_close", "btn_close_offer"]:
if tpl_name not in self.templates: pos = self.find_template_in_rel_roi(frame, tpl_name, self.REL_ROI_OFFER_CLOSE, threshold=0.65)
continue if pos:
tpl = self.templates[tpl_name] logging.info(f"[OFFER POPUP] Detected close button '{tpl_name}' at client {pos}. Dismissing...")
th, tw = tpl.shape[:2] self.click_client_pos(pos[0], pos[1])
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) time.sleep(1.0)
return True return True
return False return False
def handle_chest_open_screen(self, frame: np.ndarray): def handle_chest_open_screen(self, frame: np.ndarray):
logging.info("State: CHEST_OPEN_SCREEN.") logging.info("State: CHEST_OPEN_SCREEN.")
# Step 1: Check if "Turn all cards over" is visible # 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...") logging.info("Clicked 'Turn all cards over'. Waiting for card flip animation...")
time.sleep(2.0) time.sleep(2.0)
return return
# Step 2: Check if "Collect" is visible # 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...") logging.info("Clicked 'Collect'. Rewards claimed, returning to lobby...")
time.sleep(1.5) 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) after_frame = self.wm.capture_frame(client_only=True)
self.check_and_dismiss_offer_popup(after_frame) 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})" 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: def fast_surrender_pipeline(self, max_wait_sec: float = 15.0) -> bool:
""" """
Ultra-fast surrender pipeline for FORCED DERANK. Ultra-fast surrender pipeline for FORCED DERANK.
Polls the Exit button ROI (~20ms per check). The millisecond battle finishes loading, Polls Exit button relative ROI (~20ms per check). The millisecond battle finishes loading,
clicks Exit via fast click, then immediately polls Leave dialog ROI and confirms. 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. 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...") logging.info("[FAST DERANK] High-frequency surrender monitor engaged. Waiting for battle screen...")
t0 = time.time() 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) # Step 1: High-frequency polling for Exit button (check every 20ms)
exit_clicked = False exit_clicked = False
@ -393,14 +416,14 @@ class DirtyLeagueBot:
time.sleep(0.02) time.sleep(0.02)
continue 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: if exit_pos:
logging.info(f"[FAST DERANK] Battle loaded! 'Exit' detected at {exit_pos} in {time.time() - t0:.2f}s. Clicking...") 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]) self.click_client_pos_fast(exit_pos[0], exit_pos[1])
exit_clicked = True exit_clicked = True
break 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: if leave_pos:
self.click_client_pos_fast(leave_pos[0], leave_pos[1]) self.click_client_pos_fast(leave_pos[0], leave_pos[1])
logging.info("[FAST DERANK] Clicked existing 'Leave' confirmation.") logging.info("[FAST DERANK] Clicked existing 'Leave' confirmation.")
@ -422,7 +445,7 @@ class DirtyLeagueBot:
time.sleep(0.02) time.sleep(0.02)
continue 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: if leave_pos:
self.click_client_pos_fast(leave_pos[0], leave_pos[1]) 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.") 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): def handle_lobby(self, frame: np.ndarray):
logging.info("State: TOWER_LOBBY.") logging.info("State: TOWER_LOBBY.")
self._result_recorded_for_match = False self._result_recorded_for_match = False
fh, fw = frame.shape[:2]
# Read trophies counter above FIGHT button # Read trophies counter above FIGHT button
trophies = ocr_reader.read_trophies(frame) trophies = ocr_reader.read_trophies(frame)
if trophies: if trophies:
@ -453,8 +478,10 @@ class DirtyLeagueBot:
c_current, c_max = crown_info c_current, c_max = crown_info
logging.info(f"[CROWN CHEST] Progress: {c_current}/{c_max} [CROWN]") logging.info(f"[CROWN CHEST] Progress: {c_current}/{c_max} [CROWN]")
if c_current >= c_max: if c_current >= c_max:
logging.info(f"[CROWN CHEST] Goal reached ({c_current}/{c_max})! Clicking crown chest at (875, 1260)...") logging.info(f"[CROWN CHEST] Goal reached ({c_current}/{c_max})! Clicking crown chest...")
self.click_client_pos(875, 1260) 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) time.sleep(1.5)
return return
@ -471,7 +498,7 @@ class DirtyLeagueBot:
return return
logging.info("Searching for 'btn_fight' / 'btn_play'...") 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_fight") or \
self.find_template(frame, "btn_play") self.find_template(frame, "btn_play")
if fight_pos: if fight_pos:
@ -499,7 +526,12 @@ class DirtyLeagueBot:
self.current_wins += 1 self.current_wins += 1
self.total_games += 1 self.total_games += 1
logging.info(f"[WIN] Victory recorded! Streak: {self.current_wins}/{self.max_wins_limit}. Total games: {self.total_games}") 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") self.click_template(frame, "btn_continue")
time.sleep(1.0) time.sleep(1.0)
@ -509,17 +541,26 @@ class DirtyLeagueBot:
self.current_wins = 0 self.current_wins = 0
self.total_games += 1 self.total_games += 1
logging.info(f"[LOSS] Streak reset to 0. Total games: {self.total_games}") 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") self.click_template(frame, "btn_continue")
time.sleep(1.0) time.sleep(1.0)
def handle_no_free_slots(self, frame: np.ndarray): def handle_no_free_slots(self, frame: np.ndarray):
logging.info("State: NO_FREE_SLOTS_SCREEN ('You have no slot available for this chest').") 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: if open_pos:
logging.debug(f"[NO_FREE_SLOTS] 'Open chest' option detected at {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...") logging.info("Clicked 'Remove chest' to discard unslotted chest. Returning to lobby...")
time.sleep(1.0) time.sleep(1.0)

View File

@ -62,14 +62,19 @@ class OcrReader:
""" """
Extracts trophies counter (current, max) from TOWER_LOBBY screen. Extracts trophies counter (current, max) from TOWER_LOBBY screen.
Expected format: '2125/2300' -> returns (2125, 2300). 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] fh, fw = frame.shape[:2]
if fh < 1000 or fw < 2000: if fh < 300 or fw < 500:
return None 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) text = self.read_text(trophy_crop)
# Clean up text and parse # Clean up text and parse
@ -85,35 +90,54 @@ class OcrReader:
""" """
Extracts crown chest counter (current, max) from TOWER_LOBBY screen. Extracts crown chest counter (current, max) from TOWER_LOBBY screen.
Expected format: '3/5' or '5/5' -> returns (current, 5). 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] fh, fw = frame.shape[:2]
if fh < 1000 or fw < 2000: if fh < 300 or fw < 500:
return None 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) # 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(assets_dir, "digit_crown_5.png")
tpl_5_path = os.path.join(os.path.dirname(__file__), "assets", "digit_crown_5.png")
if os.path.exists(tpl_5_path): if os.path.exists(tpl_5_path):
tpl_5 = cv2.imread(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]: 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) res_5 = cv2.matchTemplate(digit_roi, tpl_5, cv2.TM_CCOEFF_NORMED)
_, max_v5, _, _ = cv2.minMaxLoc(res_5) _, max_v5, _, _ = cv2.minMaxLoc(res_5)
if max_v5 >= 0.88: if max_v5 >= 0.85:
return 5, 5 return 5, 5
# 2. Template check for 3/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): if os.path.exists(tpl_3_path):
tpl_3 = cv2.imread(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]: 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) res_3 = cv2.matchTemplate(digit_roi, tpl_3, cv2.TM_CCOEFF_NORMED)
_, max_v3, _, _ = cv2.minMaxLoc(res_3) _, max_v3, _, _ = cv2.minMaxLoc(res_3)
if max_v3 >= 0.88: if max_v3 >= 0.85:
return 3, 5 return 3, 5
# 3. Fallback to Windows native OCR # 3. Fallback to Windows native OCR
badge_crop = frame[1380:1470, 740:1000] by1, by2 = int(fh * 0.6732), int(fh * 0.7171)
w = 1000 - 740 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)] 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) up = cv2.resize(text_part, (0, 0), fx=3.0, fy=3.0, interpolation=cv2.INTER_CUBIC)
text = self.read_text(up) text = self.read_text(up)

114
test_resolution_scaling.py Normal file
View File

@ -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()

View File

@ -193,6 +193,21 @@ class WindowManager:
c_left, c_top, _, _ = self.get_client_rect() c_left, c_top, _, _ = self.get_client_rect()
return c_left + x, c_top + y 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__": if __name__ == "__main__":
wm = WindowManager() wm = WindowManager()