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