diff --git a/.gitignore b/.gitignore index 9365c048..4f8447b6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ src-tauri/logs/ .idea/ -.env \ No newline at end of file +.env +.vscode/launch.json +*.png \ No newline at end of file diff --git a/src-tauri/backend/bot/combat_mode_v2.py b/src-tauri/backend/bot/combat_mode_v2.py new file mode 100644 index 00000000..1421cba9 --- /dev/null +++ b/src-tauri/backend/bot/combat_mode_v2.py @@ -0,0 +1,343 @@ +from typing import List, Optional +from time import sleep +from numpy import random + + +# from bot.game import Game +from utils.settings import Settings +from utils.message_log import MessageLog as Log +from utils.image_utils import ImageUtils +from utils.mouse_utils import MouseUtils +from bot.window import Window + +class CombatModeV2: + """ This is a class that manager everything inside a battle + """ + + actions = [] + _attack_button_location = None + + + @staticmethod + def _enable_auto() -> bool: + """Enable Full/Semi auto for this battle. + + Returns: + (bool): True if Full/Semi auto is enabled. + """ + from bot.game import Game + + if Settings.enable_refresh_during_combat and Settings.enable_auto_quick_summon: + Log.print_message(f"[COMBAT] Automatically attempting to use Quick Summon...") + CombatModeV2._quick_summon() + + enable_auto = Game.find_and_click_button("full_auto") or ImageUtils.find_button("full_auto_enabled") + + # If the bot failed to find and click the "Full Auto" button, fallback to the "Semi Auto" button. + if enable_auto is False: + Log.print_message(f"[COMBAT] Failed to find the \"Full Auto\" button. Falling back to Semi Auto.") + Log.print_message(f"[COMBAT] Double checking to see if Semi Auto is enabled.") + + enabled_semi_auto_button_location = ImageUtils.find_button("semi_button_enabled") + if enabled_semi_auto_button_location is None: + # Have the Party attack and then attempt to see if the "Semi Auto" button becomes visible. + Game.find_and_click_button("attack") + + Game.wait(2.0) + + enable_auto = Game.find_and_click_button("semi_auto", tries = 10) + if enable_auto: + Log.print_message("[COMBAT] Semi Auto is now enabled.") + else: + Log.print_message(f"[COMBAT] Enabled Full Auto.") + + return enable_auto + + @staticmethod + def _select_char(idx: int): + """Click on the character on combact screen. Idx start at 0 + """ + x,y = ImageUtils.find_button("attack", tries = 50, bypass_general_adjustment = True) + + if Settings.use_first_notch is False: + x_offset = 280 + x_inc = 80 + else: + x_offset = 215 + x_inc = 55 + + x = x - x_offset + (x_inc * idx-2) + y = y + 92 + MouseUtils.move_and_click_point(x, y, "template_character") + + @staticmethod + def _change_select_char(idx: int): + """Given that a character is select, change to select another char + """ + from bot.game import Game + Game.find_and_click_button("back") + CombatModeV2._select_char(idx) + + @staticmethod + def _deselect_char(): + """Click on the back button + """ + from bot.game import Game + Game.find_and_click_button("back") + + @staticmethod + def _use_skill(idx: int): + + from bot.game import Game + + x,y = ImageUtils.find_button("attack", tries = 50, bypass_general_adjustment = True) + + if Settings.use_first_notch is False: + # calibrated + x_offset = -159 + x_inc = 84 + y_offset = 178 + else: + x_offset = 145 + x_inc = 55 + y_offset = 115 + + x = x + x_offset + (x_inc * idx) + y = y + y_offset + + MouseUtils.move_and_click_point(x, y, "template_skill") + Log.print_message(f"[COMBAT] Use Skill {idx}.") + + @staticmethod + def _quick_summon(command: str = ""): + """Activate a Quick Summon. + + Args: + command (str, optional): The command to be executed. Defaults to the regular quick summon command. + + Returns: + (bool): Return True if the Turn will end due to a chained "attack" command. False otherwise. + """ + from bot.game import Game + + Log.print_message("[COMBAT] Quick Summoning now...") + if ImageUtils.find_button("quick_summon_not_ready", bypass_general_adjustment = True) is None and \ + (Game.find_and_click_button("quick_summon1", bypass_general_adjustment = True) or Game.find_and_click_button("quick_summon2", bypass_general_adjustment = True)): + Log.print_message("[COMBAT] Successfully quick summoned!") + + if "wait" in command: + split_command = command.split(".") + split_command.pop(0) + CombatModeV2._wait_execute(split_command) + + if "attack" in command: + CombatModeV2._end() + return True + else: + Log.print_message("[COMBAT] Was not able to quick summon this Turn.") + + return False + + @staticmethod + def _enable_semi_auto(): + """Enable Semi Auto and if it fails, try to enable Full Auto. + """ + from bot.game import Game + + Log.print_message("[COMBAT] Bot will now attempt to enable Semi Auto...") + CombatModeV2._semi_auto = ImageUtils.find_button("semi_auto_enabled") + if not CombatModeV2._semi_auto: + # Have the Party attack and then attempt to see if the "Semi Auto" button becomes visible. + Game.find_and_click_button("attack") + CombatModeV2._semi_auto = Game.find_and_click_button("semi_auto") + + # If the bot still cannot find the "Semi Auto" button, that probably means the user has the "Full Auto" button on the screen instead of the "Semi Auto" button. + if not CombatModeV2._semi_auto: + Log.print_message("[COMBAT] Failed to enable Semi Auto. Falling back to Full Auto...") + + # Enable Full Auto. + CombatModeV2._full_auto = Game.find_and_click_button("full_auto") + else: + Log.print_message("[COMBAT] Semi Auto is now enabled.") + + return None + + @staticmethod + def _enable_full_auto(): + """Enable Full Auto and if it fails, try to enable Semi Auto. + + """ + from bot.game import Game + + Log.print_message("[COMBAT] Bot will now attempt to enable Full Auto...") + + # If the bot failed to find and click the "Full Auto" button, fallback to the "Semi Auto" button. + if not Game.find_and_click_button("full_auto"): + Log.print_message("[COMBAT] Bot failed to find the \"Full Auto\" button. Falling back to Semi Auto.") + CombatModeV2._enable_semi_auto() + else: + Log.print_message("[COMBAT] Full Auto is now enabled.") + + @staticmethod + def _back(): + """Attacks and then presses the Back button to quickly end animations. + """ + from bot.game import Game + + if Game.find_and_click_button("attack"): + if ImageUtils.wait_vanish("combat_cancel", timeout = 10): + Log.print_message("[COMBAT] Attacked and pressing the Back button now...") + CombatModeV2._back(increment_turn = False) + + else: + Log.print_message("[WARNING] Failed to execute the \"attackback\" command...") + + @staticmethod + def _sub_back(): + """Presses the Back button. + """ + from bot.game import Game + + x, y = ImageUtils.find_button("home_back", tries = 30, is_sub=True) + MouseUtils.move_and_click_point( + x, y, "home_back" + ) + + Log.print_message("[COMBAT] Tapped the Back button of sub Window.") + + return None + + @staticmethod + def _sub_reload(): + Window.reload(is_sub=True) + + @staticmethod + def _reload(): + from bot.game import Game + Log.print_message("[COMBAT] Bot will now attempt to manually reload...") + + if (random.rand() > 0.1): + Window.reload() + else: + Game.find_and_click_button("reload") + + Game.wait(3.0) + + @staticmethod + def _attack(): + """Attacks and if there is a wait command attached, execute that as well. + """ + from bot.game import Game + + if Game.find_and_click_button("attack", tries = 30): + if ImageUtils.wait_vanish("combat_cancel", timeout = 10): + Log.print_message("[COMBAT] Successfully executed a manual attack.") + else: + Log.print_message("[COMBAT] Successfully executed a manual attack that resolved instantly.") + else: + Log.print_message("[WARNING] Failed to execute a manual attack.") + + @staticmethod + def load_actions(actions): + """ Check the string list and load the actions chain into the combat mode + + actions: list of tuple of function name and parameter + """ + fun = { + "quicksummon": CombatModeV2._quick_summon, + "enablefullauto": CombatModeV2._enable_full_auto, + "enablesemiauto": CombatModeV2._enable_semi_auto, + "back": CombatModeV2._back, + "selectchar": CombatModeV2._select_char, + "changechar": CombatModeV2._change_select_char, + "useskill": CombatModeV2._use_skill, + "subback": CombatModeV2._sub_back, + "deselectchar": CombatModeV2._deselect_char, + "reload": CombatModeV2._reload, + "attack": CombatModeV2._attack, + "_sub_reload": CombatModeV2._sub_reload, + "_wait_for_end": CombatModeV2._wait_for_end + } + CombatModeV2.actions = [] + for action in actions: + CombatModeV2.actions.append( + (fun[action[0]] , action[1]) + ) + Log.print_message( + f"[COMBAT] Action Load: Size {len(CombatModeV2.actions)}") + + @staticmethod + def _is_battle_end() -> bool: + """checks to see if the battle ended or not. + + Returns: + (str): Return "Nothing" if combat is still continuing. Otherwise, raise a CombatModeV2Exception whose message is the event name that caused the battle to end. + """ + from bot.game import Game + + # Check if the Battle has ended. + if ImageUtils.confirm_location("battle_concluded", tries = 1, suppress_error = True, bypass_general_adjustment = True): + Log.print_message("\n[COMBAT] Battle concluded suddenly.") + Log.print_message("\n######################################################################") + Log.print_message("######################################################################") + Log.print_message("[COMBAT] Ending Combat Mode.") + Log.print_message("######################################################################") + Log.print_message("######################################################################") + return True + + if ImageUtils.confirm_location("exp_gained", tries = 1, suppress_error = True, bypass_general_adjustment = True): + Log.print_message("\n######################################################################") + Log.print_message("######################################################################") + Log.print_message("[COMBAT] Ending Combat Mode.") + Log.print_message("######################################################################") + Log.print_message("######################################################################") + return True + + if ImageUtils.confirm_location("loot_collected", tries = 1, suppress_error = True, bypass_general_adjustment = True): + Log.print_message("\n######################################################################") + Log.print_message("######################################################################") + Log.print_message("[COMBAT] Ending Combat Mode.") + Log.print_message("######################################################################") + Log.print_message("######################################################################") + return True + + return False + + @staticmethod + def _wait_for_end(): + from bot.game import Game + Game._move_mouse_security_check() + while not CombatModeV2._is_battle_end(): + sleep(5) + + @staticmethod + def start_combat_mode() -> bool: + """Start Combat Mode. + + Returns: + if Combat Mode successful start + """ + + Log.print_message("\n######################################################################") + Log.print_message("######################################################################") + Log.print_message(f"[COMBAT] Starting Combat Mode.") + Log.print_message("######################################################################") + Log.print_message("######################################################################\n") + + CombatModeV2._attack_button_location = ImageUtils.find_button("attack", tries = 100, bypass_general_adjustment = True) + + if CombatModeV2._attack_button_location is None: + Log.print_message(f"\n[ERROR] Cannot find Attack button.") + return False + + # excute chain actions + for action in CombatModeV2.actions: + action[0](**action[1]) + + Log.print_message("\n######################################################################") + Log.print_message("######################################################################") + Log.print_message("[COMBAT] Ending Combat Mode.") + Log.print_message("######################################################################") + Log.print_message("######################################################################") + + return True \ No newline at end of file diff --git a/src-tauri/backend/bot/game.py b/src-tauri/backend/bot/game.py index de8e0ef2..1e732cc8 100644 --- a/src-tauri/backend/bot/game.py +++ b/src-tauri/backend/bot/game.py @@ -27,6 +27,8 @@ from bot.game_modes.special import Special from bot.game_modes.xeno_clash import XenoClash from bot.game_modes.generic import Generic +from bot.game_modes.generic_v2 import GenericV2 +from bot.window import Window class Game: @@ -965,7 +967,11 @@ def start_farming_mode(): return True # Calibrate the dimensions of the bot window on bot launch. - Game._calibrate_game_window(display_info_check = True) + if Settings.farming_mode == "Generic V2": + Window.calibrate() + else: + Game._calibrate_game_window(display_info_check = True) + if Settings.item_name != "EXP": MessageLog.print_message("\n######################################################################") @@ -1023,6 +1029,9 @@ def start_farming_mode(): ArcarumSandbox.start() elif Settings.farming_mode == "Generic": Generic.start() + elif Settings.farming_mode == "Generic V2": + GenericV2.start() + break if Settings.item_amount_farmed < Settings.item_amount_to_farm: # Generate a resting period if the user enabled it. @@ -1062,4 +1071,4 @@ def start_farming_mode(): Game._move_mouse_security_check() - return True + return True \ No newline at end of file diff --git a/src-tauri/backend/bot/game_modes/generic_v2.py b/src-tauri/backend/bot/game_modes/generic_v2.py new file mode 100644 index 00000000..66ddcd68 --- /dev/null +++ b/src-tauri/backend/bot/game_modes/generic_v2.py @@ -0,0 +1,108 @@ +from utils.message_log import MessageLog as Log +from utils.settings import Settings +from utils.image_utils import ImageUtils +from bot.window import Window +from bot.combat_mode_v2 import CombatModeV2 as Combat +from utils.parser import Parser +import numpy as np +from time import sleep + +class GenericV2: + """ + Provides more lightweight utility functions with less limitation for more simple mission. + """ + + @staticmethod + def start(): + + from bot.game import Game + ImageUtils._summon_selection_first_run = False + + Log.print_message(f"[GenericV2] Parsing combat script: {Settings.combat_script_name}") + battles_seq = Parser.parse_battles(Settings.combat_script) + + for battle in battles_seq: + + config, actions = battle + url, summon, repeat = config + + Log.print_message(f"[GenericV2] Start battle:{url}, total of {repeat} times") + + if actions[-1][0] == "subback": + # first time + Log.print_message(f"[GenericV2] First run with support window") + if Window.sub_start == None: + raise RuntimeError("There are no support Window.") + + Window.goto(url) + Window.sub_prepare_loot() + # reload instead of back for first time + Combat.load_actions(actions[:-1] + [("_sub_reload",{})]) + # start first time + GenericV2.single_battle_sub_back(summon) + # load the original script + Combat.load_actions(actions) + + for i in range (1, repeat): + Log.print_message(f"[GenericV2] Repeat for {i} times") + GenericV2.single_battle_sub_back(summon) + Game._delay_between_runs() + if (np.random.rand() > .9): + Game._move_mouse_security_check() + + else: + if ("enablefullauto",{}) in actions: + actions.append(('_wait_for_end' ,{})) + Combat.load_actions(actions) + + for i in range (0, repeat): + + Log.print_message(f"[GenericV2] Repeat for {i+1} times") + Window.goto(url) + + GenericV2.single_raid(summon) + Game._delay_between_runs() + + if (np.random.rand() > .9): + Game._move_mouse_security_check() + + Log.print_message(f"GenericV2 successfully finish!") + + + + @staticmethod + def single_battle_sub_back(support_summon: str): + from bot.game import Game + """ Method to do single raid only with getting loot in the sub window + + support_summon: string of the support summon + """ + # Check if the bot is at the Summon Selection screen. + if not ImageUtils.confirm_location("select_a_summon", tries = 30): + raise RuntimeError("Failed to arrive at the Summon Selection screen.") + if not Game.select_summon([support_summon], Settings.summon_element_list): + raise RuntimeError("Failed to select summon") + if not Game.find_and_click_button("ok", tries = 30): + raise RuntimeError("Failed to confirm team") + if not Combat.start_combat_mode(): + raise RuntimeError("Failed to start combat mode") + if not ImageUtils.find_button("ok", tries = 30, is_sub=True): + raise RuntimeError("Failed to reach loot page") + Game.find_and_click_button("home_back") + + + + @staticmethod + def single_raid(support_summon: str): + from bot.game import Game + """ Standart method to do a battle + """ + if not ImageUtils.confirm_location("select_a_summon", tries = 30): + raise RuntimeError("Failed to arrive at the Summon Selection screen.") + if not Game.select_summon([support_summon], Settings.summon_element_list): + raise RuntimeError("Failed to select summon") + if not Game.find_and_click_button("ok", tries = 30): + raise RuntimeError("Failed to confirm team") + if not Combat.start_combat_mode(): + raise RuntimeError("Failed to start combat mode") + \ No newline at end of file diff --git a/src-tauri/backend/bot/window.py b/src-tauri/backend/bot/window.py new file mode 100644 index 00000000..952dcf4f --- /dev/null +++ b/src-tauri/backend/bot/window.py @@ -0,0 +1,159 @@ +import cv2 +from PIL import Image +from typing import List, Tuple +from utils.settings import Settings +from pyautogui import size as get_screen_size, hold, screenshot, click, write, press +import pyautogui as pya +from utils.message_log import MessageLog as Log +from utils.mouse_utils import MouseUtils as mouse +from time import sleep +from pyperclip import paste, copy + +class Window(): + + start: int = None + top: int = None + width: int = None + height: int = None + + sub_start: int = None + sub_top: int = None + sub_width: int = None + sub_height: int = None + + calibration_complete: bool = False + additional_calibration_required: bool = False + party_selection_first_run: bool = True + + + + @staticmethod + def goto(url: str, is_sub: bool = False, pattern:str = "_") -> None: + """ + Args: + is_sub: if use sub window + pattern: if match, will not go to the url + """ + if is_sub: + mouse.move_to(Window.sub_start+160, Window.sub_top-55) + else: + mouse.move_to(Window.start+160, Window.top-55) + click() + sleep(.03) + with hold('ctrl'): + press(['a', 'c']) + if paste() != url and not paste().startswith(pattern): + copy(url) + pya.hotkey('ctrl', 'v') + sleep(.03) + press('enter') + + @staticmethod + def sub_prepare_loot() -> None: + """ prepare the support window to be ready to claim loot + """ + Window.goto("https://game.granbluefantasy.jp/#quest/index", + is_sub=True) + + @staticmethod + def reload(is_sub: bool = False) -> None: + if is_sub: + mouse.move_to(Window.sub_start+160, Window.sub_top-55) + else: + mouse.move_to(Window.start+160, Window.top-55) + click() + press('f5') + + @staticmethod + def calibrate(display_info_check: bool = False) -> None: + """Calibrate the game window for fast and accurate image matching. + + Args: + display_info_check: Displays the screen size and the dimensions of the bot window. + """ + from utils.image_utils import ImageUtils + + # Save the location of the "Home" button at the bottom of the bot window. + + Log.print_message("\n[INFO] Calibrating the dimensions of the window...") + # sort coordinate from left to right + home_bttn_coords = sorted(ImageUtils.find_all("home", hide_info=True)) + back_bttn_coords = sorted(ImageUtils.find_all("home_back", hide_info=True)) + + if len(home_bttn_coords) != len(back_bttn_coords): + raise RuntimeError( + "Calibration of window dimensions failed. Some window is partially visible") + if len(home_bttn_coords) == 0: + raise RuntimeError( + "Calibration of window dimensions failed. Is the Home button on the bottom bar visible?") + if len(back_bttn_coords) == 0: + raise RuntimeError( + "Calibration of window dimensions failed. Is the back button visible on the screen?") + if len(home_bttn_coords) > 2: + raise RuntimeError( + "Calibration of window dimensions failed. maximum window is 2") + + screen_w, screen_h = get_screen_size() + + if Settings.static_window: + Log.print_message("[INFO] Using static window configuration...") + + # calibration base on the side bar + img = screenshot(region=(0,0, screen_w, screen_h)) + for win, coord in enumerate(back_bttn_coords): + for i in range (coord[0], 2, -1): + # search left to find 3 consecutive pixels which is the same as side bar + if img.getpixel((i, coord[1])) == img.getpixel((i-1, coord[1])) == \ + img.getpixel((i-2, coord[1])) == (31,31,31): + # serach up until the color is different + for j in range (coord[1], 0, -1): + if img.getpixel((i, j)) != (31,31,31): + if win==0: + Window.start = i+1 + Window.top = j+1 + Window.width = home_bttn_coords[win][0] - Window.start + 50 + Window.height = back_bttn_coords[win][1] - Window.top + 22 + else: + Window.sub_start = i+1 + Window.sub_top = j+1 + Window.sub_width = home_bttn_coords[win][0] - Window.sub_start + 50 + Window.sub_height = back_bttn_coords[win][1] - Window.sub_top + 22 + break + break + else: + Window.start = 0 + Window.top = 0 + Window.width = screen_h + Window.height = screen_w + + Window.sub_left = 0 + Window.sub_top = 0 + Window.sub_width = screen_h + Window.sub_height = screen_w + + ImageUtils.update_window_dimensions( + Window.start, + Window.top, + Window.width, + Window.height) + + if Window.start != None and Window.top != None and \ + Window.width != None and Window.height != None: + Log.print_message("[SUCCESS] Dimensions of the first window has been successfully recalibrated.") + else: + raise RuntimeError("Calibration of window dimensions failed, possbily due to side bar") + if Window.sub_start != None and Window.sub_top != None and \ + Window.sub_width != None and Window.sub_height != None: + Log.print_message("[SUCCESS] Dimensions of the second window has been successfully recalibrated.") + else: + Log.print_message("[INFO] Second Window is not presented") + + + if display_info_check: + Log.print_message("\n**********************************************************************") + Log.print_message("**********************************************************************") + Log.print_message(f"[INFO] Screen Size: {get_screen_size()}") + Log.print_message(f"[INFO] Game Window Dimensions: Region({Window.start}, {Window.top}, {Window.width}, {Window.height})") + Log.print_message(f"[INFO] Game Sub-Window Dimensions: Region({Window.sub_start}, {Window.sub_top}, {Window.sub_width}, {Window.sub_height})") + Log.print_message("**********************************************************************") + Log.print_message("**********************************************************************") \ No newline at end of file diff --git a/src-tauri/backend/unit_test/test_mouse.py b/src-tauri/backend/unit_test/test_mouse.py new file mode 100644 index 00000000..e61f6b49 --- /dev/null +++ b/src-tauri/backend/unit_test/test_mouse.py @@ -0,0 +1,9 @@ +from utils.mouse_utils import MouseUtils +from time import sleep + +for i in range (0, 1): + MouseUtils.move_and_click_point( + 750, 600, "home_back", mouse_clicks=100 + ) + sleep(1) + diff --git a/src-tauri/backend/unit_test/test_parser.py b/src-tauri/backend/unit_test/test_parser.py new file mode 100644 index 00000000..8fe86f03 --- /dev/null +++ b/src-tauri/backend/unit_test/test_parser.py @@ -0,0 +1,20 @@ +from utils.settings import Settings +from utils.parser import Parser + +case = [ "// This script starts Semi-Auto mode on Turn 1.", + "// It will go uninterrupted until either the party wipes or the quest/raid ends.", + "", + "", + "https://game.granbluefantasy.jp/#quest/supporter/800021/22", + "supportSummon:Kaguya", + "", + "quickSummon", + "subBack", + "https://game.granbluefantasy.jp/#quest/supporter/800021/22", + "supportSummon:Kaguya", + "", + "quickSummon", + "character1.useSkill(2).useSkill(4)", + "subBack"] + +print(Parser.parse_battles(case)) \ No newline at end of file diff --git a/src-tauri/backend/unit_test/test_window.py b/src-tauri/backend/unit_test/test_window.py new file mode 100644 index 00000000..e6db8a54 --- /dev/null +++ b/src-tauri/backend/unit_test/test_window.py @@ -0,0 +1,85 @@ +import cv2 as cv +import numpy as np +import pyautogui + +from utils.image_utils_v2 import ImageUtils +from bot.window import Window +from bot.game import Game +from utils.settings import Settings +from utils.mouse_utils import MouseUtils as mouse +from bot.combat_mode_v2 import CombatMode as combat + +def test_find_all(): + print(sorted(ImageUtils.find_all("home", hide_info=True))) + print(sorted(ImageUtils.find_all("home_back"))) + +# stwh = tuple of start, top , width , height +def visualize(list_of_stwh): + frame = pyautogui.screenshot(region=(0,0,1920,1080)) + img = cv.cvtColor(np.array(frame), cv.COLOR_BGR2RGB) + color = (255, 0, 0) + for stwh in list_of_stwh: + s, t, w, h = stwh + img = cv.rectangle(img, (s,t), (s+w,t+h), color, 2) + + img = cv.resize(img, (1280, 720)) + + cv.imshow("debug", img) + cv.waitKey(0) + + # closing all open windows + cv.destroyAllWindows() + +def test_find_summon(): + + s,t=ImageUtils.find_summon(Settings.summon_list, Settings.summon_element_list) + w,h=mouse._randomize_point(s,t, image_name="template_support_summon") + + visualize([(s,t,w,h)]) + +def test_calibrate(): + stwh = [(Window.start, Window.top, Window.width, Window.height)] + if Window.sub_start != None: + stwh.append( + (Window.sub_start, Window.sub_top, Window.sub_width, Window.sub_height) + ) + visualize(stwh) + +def test_find_button() : + + stwh = [] + s,t=ImageUtils.find_button("ok") + w,h=mouse._randomize_point(s,t,"ok") + if None not in (s,t): + stwh.append( + (s,t ,w-s, h-t) + ) + visualize(stwh) + +def test_character_selector(): + + stwh = [] + w,h = ImageUtils.get_button_dimensions("template_character") + for i in range(1,5): + x,y = combat._select_char(i-1) + stwh.append( + (x,y,w,h) + ) + visualize(stwh) + +def test_skill_selector(): + + # stwh = [] + # w,h = ImageUtils.get_button_dimensions("template_skill") + for i in range(1,2): + # x,y = combat._select_character(1,[1,2,3,4]) + combat._select_character(0,[1,2,3,4]) + # stwh.append( + # (x,y,w,h) + # ) + # visualize(stwh) + + +Window.calibrate(display_info_check=True) +# test_calibrate() +Window.goto("https://www.google.com") \ No newline at end of file diff --git a/src-tauri/backend/unit_test/tools.py b/src-tauri/backend/unit_test/tools.py new file mode 100644 index 00000000..ed0396e9 --- /dev/null +++ b/src-tauri/backend/unit_test/tools.py @@ -0,0 +1,44 @@ +import cv2 as cv +import numpy as np +import pyautogui + + + +def on_mouse(event,x,y,flags,param): + if event == cv.EVENT_LBUTTONDOWN: + print('x = %d, y = %d'%(x, y)) + print(frame.getpixel((x,y))) + +def check_bgr_pixel(pt1 = None , pt2 = None): + """ Get the pixel value + + Args: + pt1, pt2 : tuple points that define the scope + + Returns: + None + """ + if pt1 != None and pt2 != None: + start, top = pt1 + end, bottom = pt2 + else: + start, top = 0 , 0 + end, bottom = pyautogui.size() + + global frame + frame = pyautogui.screenshot(region=(start, top, end, bottom)) + img = cv.cvtColor(np.array(frame), cv.COLOR_BGR2RGB) + + cv.namedWindow("debug") + cv.setMouseCallback("debug", on_mouse) + + while(1): + cv.imshow("debug",img) + k = cv.waitKey(20) & 0xFF + if k == 27: + break + + # closing all open windows + cv.destroyAllWindows() + +check_bgr_pixel((0,0),(960, 1080)) \ No newline at end of file diff --git a/src-tauri/backend/utils/image_utils.py b/src-tauri/backend/utils/image_utils.py index d015a02b..e132b03b 100644 --- a/src-tauri/backend/utils/image_utils.py +++ b/src-tauri/backend/utils/image_utils.py @@ -3,7 +3,7 @@ import sys import codecs from datetime import date -from typing import List, Tuple +from typing import List, Tuple, Optional import PIL import cv2 @@ -15,11 +15,12 @@ from utils.settings import Settings from utils.message_log import MessageLog +from bot.window import Window class ImageUtils: """ - Provides the utility functions needed to perform image-related actions. + Image Utils but more basic and simple, for generic v2 """ # Initialize the following for saving screenshots. @@ -89,20 +90,25 @@ def _rescale(template: Image, scale: float) -> Image: return template.resize(size = (int(width * scale), int(height * scale)), resample = None) @staticmethod - def _match(image_path: str, confidence: float = 0.8, use_single_scale: bool = False, is_summon: bool = False) -> bool: + def _match(image_path: str, confidence: float = 0.8, \ + use_single_scale: bool = False, is_summon: bool = False, is_sub: bool = False) -> bool: """Match the given template image against the source screenshot to find a match location. Args: - image_path (str): The file path of the template image to match against in a source image. - confidence (float, optional): Accuracy threshold for matching. Defaults to 0.8. - use_single_scale (bool, optional): Use a range of scales if this is disabled. Otherwise, it will use the custom_scale value. Defaults to False. - is_summon (bool, optional): Crop out the plus signs on a summon template image before doing template matching. Defaults to False. + image_path: The file path of the template image to match against in a source image. + confidence: Accuracy threshold for matching. + use_single_scale: Use a range of scales if this is disabled. Otherwise, it will use the custom_scale value. + is_summon: Crop out the plus signs on a summon template image before doing template matching. + is_sub: if is searching on sub window. Returns: (bool): True if the template was found inside the source image and False otherwise. """ + match_check = False - if Settings.window_left is not None and Settings.window_top is not None and Settings.window_width is not None and Settings.window_height is not None: + if is_sub: + image: Image = pyautogui.screenshot(region = (Window.sub_start, Window.sub_top, Window.width, Window.sub_height)) + elif Settings.window_left is not None and Settings.window_top is not None and Settings.window_width is not None and Settings.window_height is not None: image: Image = pyautogui.screenshot(region = (Settings.window_left, Settings.window_top, Settings.window_width, Settings.window_height)) else: image: Image = pyautogui.screenshot() @@ -163,14 +169,24 @@ def _match(image_path: str, confidence: float = 0.8, use_single_scale: bool = Fa if Settings.debug_mode: cv2.imwrite(f"temp/match.png", src) - if Settings.additional_calibration_required is False: - temp_location = list(ImageUtils._match_location) - temp_location[0] += int(width / 2) - temp_location[1] += int(height / 2) + if Settings.farming_mode.endswith("V2"): + if is_sub: + temp_location = list(ImageUtils._match_location) + temp_location[0] += Window.sub_start + temp_location[1] += Window.sub_top + else: + temp_location = list(ImageUtils._match_location) + temp_location[0] += Window.start + temp_location[1] += Window.top else: - temp_location = list(ImageUtils._match_location) - temp_location[0] += (pyautogui.size()[0] - (pyautogui.size()[0] - Settings.window_left)) + int(width / 2) - temp_location[1] += (pyautogui.size()[1] - (pyautogui.size()[1] - Settings.window_top)) + int(height / 2) + if Settings.additional_calibration_required is False: + temp_location = list(ImageUtils._match_location) + temp_location[0] += int(width / 2) + temp_location[1] += int(height / 2) + else: + temp_location = list(ImageUtils._match_location) + temp_location[0] += (pyautogui.size()[0] - (pyautogui.size()[0] - Settings.window_left)) + int(width / 2) + temp_location[1] += (pyautogui.size()[1] - (pyautogui.size()[1] - Settings.window_top)) + int(height / 2) ImageUtils._match_location = tuple(temp_location) @@ -410,21 +426,24 @@ def _determine_adjustment(image_name: str) -> int: return 0 @staticmethod - def find_button(image_name: str, custom_confidence: float = Settings.confidence, tries: int = 5, suppress_error: bool = False, disable_adjustment: bool = False, - bypass_general_adjustment: bool = False, test_mode: bool = False): + def find_button( + image_name: str, custom_confidence: float = Settings.confidence, tries: int = 5, + suppress_error: bool = False, disable_adjustment: bool = False, + bypass_general_adjustment: bool = False, test_mode: bool = False, + is_sub = False) -> Optional[Tuple[int, int]]: """Find the location of the specified button. Args: - image_name (str): Name of the button image file in the /images/buttons/ folder. - custom_confidence (float, optional): Accuracy threshold for matching. Defaults to 0.8. - tries (int, optional): Number of tries before failing. Note that this gets overridden if the image_name is one of the adjustments. Defaults to 5. - suppress_error (bool, optional): Suppresses template matching error if True. Defaults to False. - disable_adjustment (bool, optional): Disable the usage of adjustment to tries. Defaults to False. - bypass_general_adjustment (bool, optional): Bypass using the general adjustment for the number of tries. Defaults to False. - test_mode (bool, optional): Flag to test and get a valid scale for device compatibility. Defaults to False. + image_name: Name of the button image file in the /images/buttons/ folder. + custom_confidence: Accuracy threshold for matching. + tries: Number of tries before failing. Note that this gets overridden if the image_name is one of the adjustments. + suppress_error: Suppresses template matching error if True. + disable_adjustment: Disable the usage of adjustment to tries. + bypass_general_adjustment: Bypass using the general adjustment for the number of tries. + test_mode: Flag to test and get a valid scale for device compatibility. Returns: - (Tuple[int, int]): Tuple of coordinates of where the center of the button is located if image matching was successful. Otherwise, return None. + Coordinates of where the center of the button is located if image matching was successful. """ if Settings.debug_mode: MessageLog.print_message(f"\n[DEBUG] Starting process to find the {image_name.upper()} button image...") @@ -445,7 +464,7 @@ def find_button(image_name: str, custom_confidence: float = Settings.confidence, while new_tries > 0: result_flag: bool = ImageUtils._match(f"{ImageUtils._current_dir}/images/buttons/{image_name.lower()}.jpg", confidence = custom_confidence, - use_single_scale = Settings.enable_test_for_home_screen) + use_single_scale = Settings.enable_test_for_home_screen, is_sub=is_sub) if result_flag is False: if test_mode: diff --git a/src-tauri/backend/utils/mouse_utils.py b/src-tauri/backend/utils/mouse_utils.py index b74bc532..06be668f 100644 --- a/src-tauri/backend/utils/mouse_utils.py +++ b/src-tauri/backend/utils/mouse_utils.py @@ -115,6 +115,11 @@ def _randomize_point(x: int, y: int, image_name: str): from utils.image_utils import ImageUtils width, height = ImageUtils.get_button_dimensions(image_name) + if Settings.farming_mode == "Generic V2": + width = np.random.randint(0,width) + height = np.random.randint(0,height) + return x+width, y+height + dimensions_x0 = x - (width // 2) dimensions_x1 = x + (width // 2) @@ -133,6 +138,8 @@ def _randomize_point(x: int, y: int, image_name: str): break return new_x, new_y + + @staticmethod def scroll_screen(x: int, y: int, scroll_clicks: int): diff --git a/src-tauri/backend/utils/parser.py b/src-tauri/backend/utils/parser.py new file mode 100644 index 00000000..f4a4be85 --- /dev/null +++ b/src-tauri/backend/utils/parser.py @@ -0,0 +1,123 @@ +from typing import List, Tuple, Dict, Optional +from utils.settings import Settings +from utils.message_log import MessageLog as Log + + +class Parser: + """ + Provides the utility functions for parsing user written combat script for \ + GenericV2 + """ + + @staticmethod + def pre_parse(text: List[str]) -> List[str]: + """ Remove all comment and empty line and lowercased result + """ + result = [] + for line in [line.strip().lower() for line in text]: + if line == "" or line.startswith("#") or line.startswith("/"): + continue + else: + result.append(line) + return result + + @staticmethod + def _parse_summon(line: str) -> str: + if not line.startswith("supportsummon:"): + raise RuntimeError( + f"[Pareser] Invalid summon: {line}") + return line.split(':')[1] + + @staticmethod + def _parse_url(line: str) -> str: + if not line.startswith("http"): + raise RuntimeError( + f"[Pareser] Invalid Url: {line}") + return line + + @staticmethod + def _parse_repeat(line: str) -> int: + if not line.startswith("repeat:"): + raise RuntimeError( + f"[Pareser] Invalid Url: {line}") + Settings.item_amount_to_farm + value = line.split(':')[1] + if value == "default": + return Settings.item_amount_to_farm + return int(value) + + + + @staticmethod + def parse_battles(text: List[str]) -> List[Tuple[Tuple[str, str, int], ...]]: + """ Parse list of text into list of tuple of + + Returns: + list of battle informations (url, summon, repeats) and combact action + """ + text = Parser.pre_parse(text) + + url: str = Parser._parse_url(text.pop(0)) + summon: str = Parser._parse_summon(text.pop(0)) + repeat: int = Parser._parse_repeat(text.pop(0)) + combact = [] + ret = [] + + while len(text)>0: + line = text.pop(0) + if not line.startswith("http"): + combact.append(line) + else: + ret.append( + ( (url, summon, repeat), Parser._parse_combact(combact)) ) + url = line + summon = Parser._parse_summon(text.pop(0)) + repeat: int = Parser._parse_repeat(text.pop(0)) + combact = [] + # end + ret.append( + ( (url, summon, repeat), Parser._parse_combact(combact)) ) + + return ret + + @staticmethod + def _parse_combact(text: List[str]) -> List[Tuple[str, Dict[str, int]]]: + """Parse the combact action + + Returns: + list of function names and function param in combact mode + """ + char_selected: Optional[int] = None + ret = [] + for line in text: + if line.startswith('character'): + chains = line.split('.') + char_idx = int(chains[0][-1]) + if char_idx not in (1,2,3,4): + raise RuntimeError( + f"[Parser] Invalid chracter number: {char_idx}") + if char_selected == None: + ret += [("selectchar", {"idx":char_idx-1} )] + elif char_selected != char_idx: + ret += [("changechar", {"idx":char_idx-1})] + char_selected = char_idx + + for cmd in chains[1:]: + + skill_idx = int(cmd[-2]) + if skill_idx not in (1,2,3,4): + raise RuntimeError( + f"[Parser] Invalid skill number: {skill_idx}") + ret.append( + ( "useskill", {"idx":skill_idx-1} ) + ) + elif line == "attack": + char_selected = None + ret.append( (line, {}) ) + elif line == "enablefullauto": + if char_selected is not None: + ret += [("deselectchar", {})] + ret += [(line, {})] + else: + ret.append( (line, {}) ) + return ret \ No newline at end of file diff --git a/src-tauri/headless.bat b/src-tauri/headless.bat new file mode 100644 index 00000000..ddad1af4 --- /dev/null +++ b/src-tauri/headless.bat @@ -0,0 +1,2 @@ +python headless.py +pause \ No newline at end of file diff --git a/src-tauri/headless.py b/src-tauri/headless.py new file mode 100644 index 00000000..8413042d --- /dev/null +++ b/src-tauri/headless.py @@ -0,0 +1,138 @@ +from subprocess import run +from os import listdir +from os.path import isfile, join +import json +from inspect import cleandoc + + +print( +""" +A simple headless utlity that run the bot and provide simple settting +utilites, Design for Generic V2 mode. +""") + +pref = {} +pref_path = "./backend/settings.json" +script_path = "./scripts" +game_mode = [ + "Generic V2", + "Generic", + "Quest", + "Special", + "Coop", + "Raid", + "Rise of the Beasts", + "Event" + "Event (Token Drawboxes)" + "Guild Wars", + "Dread Barrage", + "Proving Grounds", + "Xeno Clash", + "Arcarum", + "Arcarum Sandbox", +] + +with open(pref_path, "r") as jsonFile: + pref = json.load(jsonFile) + + +def write_file(): + """Call me every time you change the pref value""" + with open(pref_path, "w") as jsonFile: + json.dump(pref, jsonFile, indent=4) + +def print_status(): + print(cleandoc( + f"""--------------------------------------------------- + l :load a script {pref['game']['combatScriptName']} + m :change game mode {pref['game']['farmingMode']} + i :change repeat time/item to farm {pref['game']['itemAmount']} + q :quit current operation + + enter :run the bot + --------------------------------------------------- + """)) + + + +def load_script(): + scripts = [f for f in listdir(script_path) if isfile(join(script_path, f))] + print("---------------------------------------------------") + for i, script in enumerate(scripts): + print(f"{i} -> {script}") + print("---------------------------------------------------") + while True: + try: + ins = input("Select a script index: ") + if ins == 'q': return + idx = int(ins) + if idx < 0 or idx > len(scripts)-1 : + raise ValueError + except ValueError: + print("Invalid number: Try again") + else: + with open(f"{script_path}/{scripts[idx]}") as script_file: + pref['game']['combatScript'] = script_file.read().splitlines() + pref['game']['combatScriptName'] = scripts[idx] + write_file() + print(f"script: {scripts[idx]} is succesfully loaded") + break + + +def change_mode(): + print("---------------------------------------------------") + for i, mode in enumerate(game_mode): + print(f"{i} -> {mode}") + print("---------------------------------------------------") + while True: + try: + ins = input("Select a game mode: ") + if ins == 'q': return + idx = int(ins) + if idx < 0 or idx > len(game_mode)-1 : + raise ValueError + except ValueError: + print("Invalid number: Try again") + else: + pref['game']['farmingMode'] = game_mode[idx] + write_file() + print(f"Game Mode succesfullt changed to {game_mode[idx]}") + break + + + +def change_item_amount(): + print("---------------------------------------------------") + while True: + try: + ins = input("Input the repeat times/ amount of items: ") + if ins == 'q': return + amount = int(ins) + if amount < 1: + raise ValueError + except ValueError: + print("Invalid number: Try again") + else: + pref['game']['itemAmount'] = amount + write_file() + print(f"Item amount succesfully changed to {amount}") + break + + +while True: + print_status() + cmd = input("What would you like to do? :") + if cmd == 'l': + load_script() + elif cmd == 'm': + change_mode() + elif cmd == 'i': + change_item_amount() + elif cmd == '': + print("Bot starting now, user Ctrl-c to force terminate") + run(["python", "./backend/main.py"]) + elif cmd == 'q': + print("Bot quit successfully") + break + else: + print(f"Invalid command: {cmd}\n") \ No newline at end of file diff --git a/src-tauri/scripts/campaign_exclusive_mw_v2.txt b/src-tauri/scripts/campaign_exclusive_mw_v2.txt new file mode 100644 index 00000000..7b903624 --- /dev/null +++ b/src-tauri/scripts/campaign_exclusive_mw_v2.txt @@ -0,0 +1,8 @@ +// Script that play campagin exclusive using multi window + +https://game.granbluefantasy.jp/#quest/supporter/800011/22 +supportSummon:Kaguya +repeat:default + +quickSummon +subBack \ No newline at end of file diff --git a/src-tauri/scripts/campaign_exclusive_v2.txt b/src-tauri/scripts/campaign_exclusive_v2.txt new file mode 100644 index 00000000..1235d7dd --- /dev/null +++ b/src-tauri/scripts/campaign_exclusive_v2.txt @@ -0,0 +1,8 @@ +// Script that play campagin exclusive using single window + +https://game.granbluefantasy.jp/#quest/supporter/800011/22 +supportSummon:Kaguya +repeat:default + +quickSummon +reload \ No newline at end of file diff --git a/src-tauri/scripts/six_dragon_raid_v2.txt b/src-tauri/scripts/six_dragon_raid_v2.txt new file mode 100644 index 00000000..554493e4 --- /dev/null +++ b/src-tauri/scripts/six_dragon_raid_v2.txt @@ -0,0 +1,54 @@ +// Script that auto host all 6 dragon raids + +//Wilnas +https://game.granbluefantasy.jp/#quest/supporter/305191/1/0/41 +supportSummon:leviathan_omega +//supportSummon:varuna +repeat:1 + +enablefullauto + +//Wamdus +https://game.granbluefantasy.jp/#quest/supporter/305201/1/0/42 +supportSummon:yggdrasil_omega +//supportSummon:titan +repeat:1 + +enablefullauto + +//Galleon +https://game.granbluefantasy.jp/#quest/supporter/305211/1/0/43 +supportSummon:tiamat_omega +//supportSummon:zephyrus +repeat:1 + +enablefullauto + +//Ewiyar +https://game.granbluefantasy.jp/#quest/supporter/305221/1/0/44 +supportSummon:colossus_omega +//supportSummon:agni +repeat:1 + +enablefullauto + +//Lu Woh +https://game.granbluefantasy.jp/#quest/supporter/305231/1/0/45 +supportSummon:celeste_omega +//supportSummon:hades +repeat:1 + +character3.useSkill(3) +enablefullauto + +//Fediel +https://game.granbluefantasy.jp/#quest/supporter/305241/1/0/46 +supportSummon:luminiera_omega +//supportSummon:zeus +repeat:1 + +enablefullauto + + + + diff --git a/src-tauri/scripts/template_v2.txt b/src-tauri/scripts/template_v2.txt new file mode 100644 index 00000000..f9cc58e9 --- /dev/null +++ b/src-tauri/scripts/template_v2.txt @@ -0,0 +1,32 @@ +// v2 script is similar to normal script, but you cannot specific action base on turns, +// You can try v2 script by using the headless.bat file +//You need to first specify the url of the supporter page, aka "summon page" + +https://game.granbluefantasy.jp/#quest/supporter/305231/1/0/45 +//Then you need to specify a summon, the name are located in images/summons +supportSummon:celeste_omega +// Then you need to specify the time of repeat, or use "default" to use the value set +// in the launcher +repeat:1 +//repeat:default + +// Then you can write the combact script without turn +// Note that the only option support now is +// "quickSummon", +// "enableFullAuto", +// "back", +// "character(#)", +// "useSkill(#)", +// "subBack", <- new option that press the back button on second window +// "reload", +// "attack" + +character3.useSkill(3) +enablefullauto + +// you can continue to specify more battles +https://game.granbluefantasy.jp/#quest/supporter/305241/1/0/46 +supportSummon:zeus +repeat:1 + +enablefullauto