Launch botty from town or main menu; fix character recovery after restart/death (#494)

* Add ability to launch botty from either main menu or town

* Fix game recovery routine is stuck forever on character selection screen

* Move _char_template to class variable to be able to share among instances

* fix character recovery and character select, support offline/online. WIP

Co-authored-by: Vladimir Makayev <[email protected]>
Co-authored-by: mgleed <[email protected]>
This commit is contained in:
Vladimir Makaev
2022-02-04 20:27:56 -05:00
committed by GitHub
co-authored by Vladimir Makayev mgleed
parent c57e820d63
commit c358282e68
11 changed files with 168 additions and 89 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+5 -8
View File
@@ -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
+51 -17
View File
@@ -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"]:
+3 -13
View File
@@ -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
+4 -16
View File
@@ -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())
+9 -7
View File
@@ -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
+1
View File
@@ -1,2 +1,3 @@
from .ui_manager import UiManager
from .belt_manager import BeltManager
from .char_selector import CharSelector
+94 -27
View File
@@ -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()
+1 -1
View File
@@ -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)