diff --git a/assets/templates/character_active.png b/assets/templates/character_active.png new file mode 100644 index 0000000..6789e90 Binary files /dev/null and b/assets/templates/character_active.png differ diff --git a/assets/templates/character_state_offline.png b/assets/templates/character_state_offline.png new file mode 100644 index 0000000..423c78c Binary files /dev/null and b/assets/templates/character_state_offline.png differ diff --git a/assets/templates/character_state_online.png b/assets/templates/character_state_online.png new file mode 100644 index 0000000..f4ae490 Binary files /dev/null and b/assets/templates/character_state_online.png differ diff --git a/config/game.ini b/config/game.ini index dc1068f..afbb78d 100644 --- a/config/game.ini +++ b/config/game.ini @@ -20,11 +20,6 @@ screen_width=1280 screen_height=720 center_x=640 center_y=360 -ingame_ref_x=625 -ingame_ref_y=669 -offline_width=105 -char_selection_center_x=1050 -char_selection_center_y=270 ; some distance skill_bar_height=67 save_and_exit_x=633 @@ -121,13 +116,15 @@ no_pickup=5,530,240,65 repair_needed=1000,0,280,280 wp_act_roi=42,66,365,53 stash_btn_roi=20,52,412,53 -offline_tab= 1100,10,150,40 -char_selection_top=1047,50,160,37 -char_selection_all=1042,45,170,550 enemy_info=440,15,405,150 +character_select=1033, 44, 226, 554 +character_online_status=1033, 19, 226, 25 +; character_sub_roi is with respect to matched active character template +character_name_sub_roi=8, 22, 192, 32 cube_area_roi=167,209,113,152 cube_btn_roi=160,368,125,57 + [path] ; static pathes in format: x0,y0, x1,y1, x2,y2, ... pindle_safe_dist=921,35, 1123,70, 960,88 diff --git a/src/bot.py b/src/bot.py index cf80316..61ef870 100644 --- a/src/bot.py +++ b/src/bot.py @@ -9,7 +9,6 @@ from copy import copy from typing import Union from collections import OrderedDict from transmute import Transmute - from utils.misc import wait from game_stats import GameStats from logger import Logger @@ -19,8 +18,9 @@ from template_finder import TemplateFinder from char import IChar from item import ItemFinder from item.pickit import PickIt -from ui import UiManager +from ui import UiManager, char_selector from ui import BeltManager +from ui import CharSelector from pather import Pather, Location from npc_manager import NpcManager from health_manager import HealthManager @@ -34,13 +34,15 @@ from char.basic import Basic from char.basic_ranged import Basic_Ranged from run import Pindle, ShenkEld, Trav, Nihlathak, Arcane, Diablo -from town import TownManager, A1, A2, A3, A4, A5 +from town import TownManager, A1, A2, A3, A4, A5, town_manager # Added for dclone ip hunt from messages import Messenger from utils.dclone_ip import get_d2r_game_ip class Bot: + _MAIN_MENU_MARKERS = ["MAIN_MENU_TOP_LEFT","MAIN_MENU_TOP_LEFT_DARK"] + def __init__(self, screen: Screen, game_stats: GameStats, template_finder: TemplateFinder, pick_corpse: bool = False): self._screen = screen self._game_stats = game_stats @@ -123,11 +125,15 @@ class Bot: self._current_threads = [] self._no_stash_counter = 0 self._ran_no_pickup = False + self._char_selector = CharSelector(self._screen, self._template_finder) # Create State Machine - self._states=['hero_selection', 'town', 'pindle', 'shenk', 'trav', 'nihlathak', 'arcane', 'diablo'] + self._states=['initialization','hero_selection', 'town', 'pindle', 'shenk', 'trav', 'nihlathak', 'arcane', 'diablo'] self._transitions = [ - { 'trigger': 'create_game', 'source': 'hero_selection', 'dest': 'town', 'before': "on_create_game"}, + { 'trigger': 'init', 'source': 'initialization', 'dest': '=','before': "on_init"}, + { 'trigger': 'select_character', 'source': 'initialization', 'dest': 'hero_selection', 'before': "on_select_character"}, + { 'trigger': 'start_from_town', 'source': ['initialization', 'hero_selection'], 'dest': 'town', 'before': "on_start_from_town"}, + { 'trigger': 'create_game', 'source': 'hero_selection', 'dest': '=', 'before': "on_create_game"}, # Tasks within town { 'trigger': 'maintenance', 'source': 'town', 'dest': 'town', 'before': "on_maintenance"}, # Different runs @@ -139,16 +145,16 @@ class Bot: { 'trigger': 'run_diablo', 'source': 'town', 'dest': 'nihlathak', 'before': "on_run_diablo"}, # End run / game { 'trigger': 'end_run', 'source': ['shenk', 'pindle', 'nihlathak', 'trav', 'arcane', 'diablo'], 'dest': 'town', 'before': "on_end_run"}, - { 'trigger': 'end_game', 'source': ['town', 'shenk', 'pindle', 'nihlathak', 'trav', 'arcane', 'diablo','end_run'], 'dest': 'hero_selection', 'before': "on_end_game"}, + { 'trigger': 'end_game', 'source': ['town', 'shenk', 'pindle', 'nihlathak', 'trav', 'arcane', 'diablo','end_run'], 'dest': 'initialization', 'before': "on_end_game"}, ] - self.machine = Machine(model=self, states=self._states, initial="hero_selection", transitions=self._transitions, queued=True) + self.machine = Machine(model=self, states=self._states, initial="initialization", transitions=self._transitions, queued=True) self._transmute = Transmute(self._screen, self._template_finder, self._game_stats, self._ui_manager) def draw_graph(self): # Draw the whole graph, graphviz binaries must be installed and added to path for this! from transitions.extensions import GraphMachine - self.machine = GraphMachine(model=self, states=self._states, initial="hero_selection", transitions=self._transitions, queued=True) + self.machine = GraphMachine(model=self, states=self._states, initial="initialization", transitions=self._transitions, queued=True) self.machine.get_graph().draw('my_state_diagram.png', prog='dot') def get_belt_manager(self) -> BeltManager: @@ -158,7 +164,7 @@ class Bot: return self._curr_loc def start(self): - self.trigger('create_game') + self.trigger_or_stop('init') def stop(self): self._stopping = True @@ -196,14 +202,42 @@ class Bot: break return not found_unfinished_run - def on_create_game(self): - keyboard.release(self._config.char["stand_still"]) - # Start a game from hero selection - self._game_stats.log_start_game() - self._template_finder.search_and_wait(["MAIN_MENU_TOP_LEFT","MAIN_MENU_TOP_LEFT_DARK"], roi=self._config.ui_roi["main_menu_top_left"]) - if not self._ui_manager.start_game(): return - self._curr_loc = self._town_manager.wait_for_town_spawn() + def _rebuild_as_asset_to_trigger(trigger_to_assets: dict): + result = {} + for key in trigger_to_assets.keys(): + for asset in trigger_to_assets[key]: + result[asset] = key + return result + def on_init(self): + keyboard.release(self._config.char["stand_still"]) + transition_to_screens = Bot._rebuild_as_asset_to_trigger({ + "select_character": Bot._MAIN_MENU_MARKERS, + "start_from_town": town_manager.TOWN_MARKERS, + }) + match = self._template_finder.search_and_wait(list(transition_to_screens.keys()), best_match=True) + self.trigger_or_stop(transition_to_screens[match.name]) + + def on_select_character(self): + if self._config.general['restart_d2r_when_stuck']: + # Make sure the correct char is selected + if self._char_selector.has_char_template_saved(): + self._char_selector.select_char() + else: + self._char_selector.save_char_online_status() + self._char_selector.save_char_template() + + self.trigger_or_stop("create_game") + + def on_create_game(self): + # Start a game from hero selection + self._template_finder.search_and_wait(Bot._MAIN_MENU_MARKERS, roi=self._config.ui_roi["main_menu_top_left"]) + if not self._ui_manager.start_game(): return + self.trigger_or_stop("start_from_town") + + def on_start_from_town(self): + self._game_stats.log_start_game() + self._curr_loc = self._town_manager.wait_for_town_spawn() # Check for the current game ip and pause if we are able to obtain the hot ip if self._config.dclone["region_ips"] != "" and self._config.dclone["dclone_hotip"] != "": cur_game_ip = get_d2r_game_ip() @@ -349,7 +383,7 @@ class Bot: if self._config.general["randomize_runs"]: self.shuffle_runs() wait(0.2, 0.5) - self.trigger_or_stop("create_game") + self.trigger_or_stop("init") def on_end_run(self): if not self._config.char["pre_buff_every_run"]: diff --git a/src/game_controller.py b/src/game_controller.py index 6886b08..317c22e 100644 --- a/src/game_controller.py +++ b/src/game_controller.py @@ -14,7 +14,6 @@ from health_manager import HealthManager from logger import Logger from messages import Messenger from screen import Screen -from ui.char_selector import CharSelector from utils.restart import restart_game from utils.misc import kill_thread, set_d2r_always_on_top, restore_d2r_window_visibility @@ -34,17 +33,8 @@ class GameController: self.game_controller_thread = None self.bot_thread = None self.bot = None - self.char_selector = None - def run_bot(self, pick_corpse: bool = False): - if self._config.general['restart_d2r_when_stuck']: - # Make sure the correct char is selected - if self.char_selector.has_char_template_saved(): - Logger.info("Selecting original char") - self.char_selector.select_char() - else: - Logger.info("Saving top-most char as template") - self.char_selector.save_char_template() + def run_bot(self, pick_corpse: bool = False): # Start bot thread self.bot = Bot(self.screen, self.game_stats, self.template_finder, pick_corpse) self.bot_thread = threading.Thread(target=self.bot.start) @@ -100,6 +90,7 @@ class GameController: if restart_game(self._config.general["d2r_path"]): self.game_stats.log_end_game(failed=max_game_length_reached) if self.setup_screen(): + self.template_finder = TemplateFinder(self.screen) self.start_health_manager_thread() self.start_death_manager_thread() self.game_recovery = GameRecovery(self.screen, self.death_manager, self.template_finder) @@ -124,8 +115,7 @@ class GameController: self.start_health_manager_thread() self.start_death_manager_thread() self.game_recovery = GameRecovery(self.screen, self.death_manager, self.template_finder) - self.game_stats = GameStats() - self.char_selector = CharSelector(self.screen, self.template_finder) + self.game_stats = GameStats() self.start_game_controller_thread() self.is_running = True diff --git a/src/game_recovery.py b/src/game_recovery.py index 0ec9cf4..389c216 100644 --- a/src/game_recovery.py +++ b/src/game_recovery.py @@ -35,19 +35,6 @@ class GameRecovery: # lets just see if you might already be at hero selection found = self._template_finder.search(["MAIN_MENU_TOP_LEFT","MAIN_MENU_TOP_LEFT_DARK"], self._screen.grab(), roi=self._config.ui_roi["main_menu_top_left"]).valid if found: - offline_tab = self._template_finder.search(["OFFLINE_TAB","OFFLINE_TAB_DARK"], self._screen.grab(), roi=self._config.ui_roi["offline_tab"], threshold=0.8, best_match = True) - if offline_tab.valid: - if offline_tab.name == "OFFLINE_TAB_DARK": - #We need to press escape before clicking the tab - keyboard.send("esc") - time.sleep(0.2) - # Right now it selects the tab to the left of offline - offline_width = self._config.ui_pos["offline_width"] - tab = (offline_tab.center[0] - offline_width , offline_tab.center[1]) - x, y = self._screen.convert_screen_to_monitor(tab) - mouse.move(x, y, randomize=8) - time.sleep(0.5) - mouse.click(button="left") return True # would have been too easy, maybe we have died? if self._death_manager.handle_death_screen(): @@ -70,6 +57,7 @@ if __name__ == "__main__": keyboard.add_hotkey('f12', lambda: os._exit(1)) keyboard.wait("f11") screen = Screen() - death_manager = DeathManager(screen) - game_recovery = GameRecovery(screen, death_manager) - game_recovery.go_to_hero_selection() + template_finder = TemplateFinder(screen) + death_manager = DeathManager(screen, template_finder) + game_recovery = GameRecovery(screen, death_manager, template_finder) + print(game_recovery.go_to_hero_selection()) diff --git a/src/town/town_manager.py b/src/town/town_manager.py index f295542..db7cad4 100644 --- a/src/town/town_manager.py +++ b/src/town/town_manager.py @@ -9,8 +9,16 @@ from ui import UiManager from town import IAct, A1, A2, A3, A4, A5 from utils.misc import wait +TOWN_MARKERS = [ + "A5_TOWN_0", "A5_TOWN_1", + "A4_TOWN_4", "A4_TOWN_5", + "A3_TOWN_0", "A3_TOWN_1", + "A2_TOWN_0", "A2_TOWN_1", "A2_TOWN_10", + "A1_TOWN_1", "A1_TOWN_3" + ] class TownManager: + def __init__(self, template_finder: TemplateFinder, ui_manager: UiManager, item_finder: ItemFinder, a1: A1, a2: A2, a3: A3, a4: A4, a5: A5): self._config = Config() self._template_finder = template_finder @@ -44,13 +52,7 @@ class TownManager: :param time_out: Optional float value for time out in seconds, defaults to None :return: Location of the town (e.g. Location.A4_TOWN_START) or None if nothing was found within time_out time """ - template_match = self._template_finder.search_and_wait([ - "A5_TOWN_0", "A5_TOWN_1", - "A4_TOWN_4", "A4_TOWN_5", - "A3_TOWN_0", "A3_TOWN_1", - "A2_TOWN_0", "A2_TOWN_1", "A2_TOWN_10", - "A1_TOWN_1", "A1_TOWN_3" - ], best_match=True, time_out=time_out) + template_match = self._template_finder.search_and_wait(TOWN_MARKERS, best_match=True, time_out=time_out) if template_match.valid: return TownManager.get_act_from_location(template_match.name) return None diff --git a/src/ui/__init__.py b/src/ui/__init__.py index 761560d..3544a81 100644 --- a/src/ui/__init__.py +++ b/src/ui/__init__.py @@ -1,2 +1,3 @@ from .ui_manager import UiManager from .belt_manager import BeltManager +from .char_selector import CharSelector diff --git a/src/ui/char_selector.py b/src/ui/char_selector.py index 29dbf5e..636695c 100644 --- a/src/ui/char_selector.py +++ b/src/ui/char_selector.py @@ -1,52 +1,114 @@ +from operator import is_ +import cv2 +import numpy as np +import time + from config import Config from utils.custom_mouse import mouse -from utils.misc import cut_roi, wait +from utils.misc import cut_roi, roi_center, wait, is_in_roi from screen import Screen from template_finder import TemplateFinder - +from logger import Logger class CharSelector: + _last_char_template = None + _online_character = None + def __init__(self, screen: Screen, template_finder: TemplateFinder): self._config = Config() - self._char_template = None self._screen = screen self._template_finder = template_finder def has_char_template_saved(self): - return self._char_template is not None + return CharSelector._last_char_template is not None def save_char_template(self): img = self._screen.grab() - # TODO: check which character is currently selected rather than assuming the top one is the correct one - self._char_template = cut_roi(img, self._config.ui_roi["char_selection_top"]) + active_character = self._template_finder.search("CHARACTER_ACTIVE", img, roi = self._config.ui_roi["character_select"], threshold = 0.8) + if active_character.valid: + x, y, w, h = self._config.ui_roi["character_name_sub_roi"] + x, y = x + active_character.region[0], y + active_character.region[1] + char_template = cut_roi(img, [x, y, w, h]) + if self._config.general["info_screenshots"]: + cv2.imwrite("./info_screenshots/saved_character_" + time.strftime("%Y%m%d_%H%M%S") + ".png", char_template) + Logger.debug(f"Saved character template") + else: + Logger.error("save_char_template: Could not save character template") + return + CharSelector._last_char_template = char_template + + def save_char_online_status(self): + online_tabs = self._template_finder.search(["CHARACTER_STATE_OFFLINE","CHARACTER_STATE_ONLINE"], self._screen.grab(), roi=self._config.ui_roi["character_online_status"], threshold=0.8, best_match = True) + if online_tabs.valid: + if online_tabs.name == "CHARACTER_STATE_ONLINE": + online_status = True + else: + online_status = False + Logger.debug(f"Saved online status. Online={online_status}") + else: + Logger.error("save_char_online_status: Could not determine character's online status") + return + CharSelector._online_character = online_status + + def select_online_tab(self, region: tuple[int, int, int, int] = None, center: tuple[int, int] = None): + btn_width = center[0] - region[0] + if CharSelector._online_character: + Logger.debug(f"Selecting online tab") + x = region[0] + (btn_width / 2) + else: + Logger.debug(f"Selecting offline tab") + x = region[0] + (3 * btn_width / 2) + pos = self._screen.convert_screen_to_monitor((x, center[1])) + # move cursor to result and select + mouse.move(*pos) + wait(0.4, 0.6) + mouse.click(button="left") + wait(0.4, 0.6) def select_char(self): - if self._char_template is not None: + if CharSelector._last_char_template is not None: + online_tabs = self._template_finder.search(["CHARACTER_STATE_OFFLINE","CHARACTER_STATE_ONLINE"], self._screen.grab(), roi=self._config.ui_roi["character_online_status"], threshold=0.8, best_match = True) + if online_tabs.valid: + if online_tabs.name == "CHARACTER_STATE_ONLINE" and (not CharSelector._online_character): + self.select_online_tab(online_tabs.region, online_tabs.center) + elif online_tabs.name == "CHARACTER_STATE_OFFLINE" and (CharSelector._online_character): + self.select_online_tab(online_tabs.region, online_tabs.center) + wait(1, 1.5) + else: + Logger.error("select_char: Could not find online/offline tabs") + return + img = self._screen.grab() + active_char = self._template_finder.search("CHARACTER_ACTIVE", img, roi = self._config.ui_roi["character_select"], threshold = 0.8, normalize_monitor = True) + if not active_char.valid: + Logger.error("select_char: Could not find highlighted profile") + return scrolls_attempts = 0 - while scrolls_attempts < 2: # 2 scrolls should suffice to see all possible characters - template_result = self._template_finder.search( - self._char_template, - self._screen.grab(), - threshold=0.8, - roi=self._config.ui_roi["char_selection_all"], - normalize_monitor=True - ) - if template_result.valid: - # move cursor to result and select - mouse.move(*template_result.center) - wait(0.5) - mouse.click(button="left") - break + while scrolls_attempts < 2: + if scrolls_attempts > 0: + img = self._screen.grab() + desired_char = self._template_finder.search(CharSelector._last_char_template, img, roi = self._config.ui_roi["character_select"], threshold = 0.8, normalize_monitor = True) + if desired_char.valid: + if is_in_roi(active_char.region, desired_char.center) and scrolls_attempts == 0: + Logger.debug("Saved character template found and already highlighted, continue") + return + else: + Logger.debug("Selecting saved character") + mouse.move(*desired_char.center) + wait(0.4, 0.6) + mouse.click(button="left") + wait(0.4), 0.6 + return else: + Logger.debug("Highlighted profile found but saved character not in view, scroll") # We can scroll the characters only if we have the mouse in the char names selection so move the mouse there - pos = (self._config.ui_pos["char_selection_center_x"], self._config.ui_pos["char_selection_center_y"]) - pos = self._screen.convert_screen_to_monitor(pos) - mouse.move(*pos) - wait(0.5) + center = roi_center(self._config.ui_roi["character_select"]) + center = self._screen.convert_screen_to_monitor(center) + mouse.move(*center) + wait(0.4, 0.6) mouse.wheel(-14) scrolls_attempts += 1 - wait(0.5) - return False + wait(0.4, 0.6) + Logger.error(f"select_char: unable to find saved profile after {scrolls_attempts} scroll attempts") if __name__ == "__main__": @@ -59,4 +121,9 @@ if __name__ == "__main__": selector = CharSelector(screen, tf) if not selector.has_char_template_saved(): selector.save_char_template() + print("Move D2R window, select another character and press F11 to continue") + keyboard.wait("f11") + screen = Screen() + tf = TemplateFinder(screen) + selector = CharSelector(screen,tf) selector.select_char() diff --git a/src/utils/misc.py b/src/utils/misc.py index 30f7b8d..f462e64 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -112,7 +112,7 @@ def erode_to_black(img: np.ndarray, threshold: int = 14): img = cv2.bitwise_and(img, mask_color_r) return img -def roi_center(roi: list[float] = None, scale: float = 1): +def roi_center(roi: list[float] = None): x, y, w, h = roi return round(x + w/2), round(y + h/2)