diff --git a/README.md b/README.md index e528288..c1bfe02 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # BrawlStarsBot ⚠️ **DISCLAIMER!!** ****You can lose trophies while using the bot!! The bot's goal is to farm mastery**** ⚠️ -Brawl stars bot for farming mastery through solo showdown. The bot will find bushes and hide, it also attacks enemies if they are within range. Macro is integrated into the code to automate when defeated, it will queue up for another match automatically. +Brawl stars bot for farming mastery through solo showdown (and now configurable team-mode profiles). The bot will find bushes and hide (or play more aggressively in team profiles), and attack enemies if they are within range. Macro is integrated into the code to automate when defeated, it will queue up for another match automatically. ## Info Inspired by [OpenCV Object Detection in Games Python Tutorial playlist by "Learn Code By Gaming"](https://www.youtube.com/watch?v=KecMlLUuiE4&list=PL1m2M8LQlzfKtkKq2lK5xko4X-8EZzFPI) and ["How To Train YOLOv5 For Recognizing Game Objects In Real-Time" by "Jes Fink-Jensen"](https://betterprogramming.pub/how-to-train-yolov5-for-recognizing-custom-game-objects-in-real-time-9d78369928a8). @@ -16,44 +16,60 @@ A recommended map to run the bot on is island invasion, using short/medium range - Start the bot when loading in - Find the closest bush and hide in it - Attack the enemy when they are in the range +- Enemy movement prediction (short-term) to react earlier in fights +- Optional teammate-aware behavior (when your model includes a `Teammate` class) +- Configurable game mode profiles: `solo_showdown`, `team_3v3`, `team_5v5` +- Mode-aware objective search priorities (bush, cubebox, enemy) with capped reposition times for team modes +- Rank push context fields (`current_rank` / `target_rank`) for session tracking output - Activate gadget when the enemy is closer to the player - Custom Bluestack game control for the bot ## Demo of the bot -[![Watch the video](https://github.com/Jooi025/BrawlStarsBot/blob/main/misc/image/youtube_thumbnail.jpg)](https://youtu.be/TWmNfkQBVYk?si=CXaSBoAV-YknJPLt) +[![Watch the video](https://github.com/bebabinlarsson-blip/BrawlStarsBotPrompt/blob/main/misc/image/youtube_thumbnail.jpg)](https://youtu.be/TWmNfkQBVYk?si=CXaSBoAV-YknJPLt) ## Requirement * Windows OS * [Bluestacks 5](https://www.bluestacks.com/download.html) to run Brawl Star and for custom control -* Python version 3.11.6 +* Python version 3.11.6 or newer (3.14 supported) ## How to install and run the bot? ### [Watch the tutorial and common error fix playlist](https://youtube.com/playlist?list=PLD9X_geub8rmkcpJSWzvoqmB9VZk-9TfO&si=7vrCV9s1kLviRaTL) ### Clone Repo 1. Clone the repository ``` -git clone https://github.com/Jooi025/BrawlStarsBot.git +git clone https://github.com/bebabinlarsson-blip/BrawlStarsBotPrompt.git ``` 2. Install the required library ``` -cd BrawlStarsBot +cd BrawlStarsBotPrompt pip install -r requirements.txt ``` -[Writing instruction](https://github.com/Jooi025/BrawlStarsBot/blob/main/misc/textInstruction.md) +[Writing instruction](https://github.com/bebabinlarsson-blip/BrawlStarsBotPrompt/blob/main/misc/textInstruction.md) ### Update Repo ``` -cd BrawlStarsBot +cd BrawlStarsBotPrompt git pull ``` + ## Recent updates + - Improved runtime performance by reducing busy-wait CPU loops in capture/detection threads. + - Improved detection throughput by using faster list handling and model confidence pre-filtering. + - Improved brawler lookup robustness (`Mr. P`, `mr p`, `mrp` now resolve to same key format). + - Expanded `brawler_stats.json` with newer and missing brawlers for current roster coverage. + - Added configurable mode profiles for solo and team modes (`3v3`, `5v5`) with different bot behavior. + - Added class-name based detection mapping so custom models can be extended more safely. + - Added short-horizon enemy movement prediction and optional teammate-support aggression. + - Improved non-solo behavior with profile-based objective priorities and faster reposition loops. + - Added manual rank-push context fields for current/target rank visibility. + - Added cubebox attack: bot now attacks power cube boxes in range during search and movement phases. + - Player position is now estimated automatically from the detection bounding box — `heightScaleFactor` and `hsf_finder.py` are no longer required. + - Removed `pandas` and `seaborn` from `requirements.txt`; both were unused by the bot and prevented installation on Python 3.14. + ## Improvement to be made - - [ ] bot can attack power cube boxes and collect them + - [x] bot can attack power cube boxes and collect them - [x] improve detection of enemy (less false detect) - - [ ] change player detection and don't need to measure HSF + - [x] change player detection and don't need to measure HSF - [x] improve storm direction function - [x] improve the screen detection of "defeated" - [x] fix spam printing of "stop bot" - [x] improve fps for lower performance computer - - - diff --git a/brawler_stats.json b/brawler_stats.json index d11cb6e..61b772b 100644 --- a/brawler_stats.json +++ b/brawler_stats.json @@ -107,6 +107,39 @@ "sandy":[2.57,6,0.157], + "spike":[2.4,7.67], + "crow":[2.73,8.67], + "leon":[2.73,9.33], + "amber":[2.4,8.67], + "meg":[2.4,7.33], + "gale":[2.4,8.33], + "surge":[2.4,8], + "colette":[2.4,9], + "belle":[2.4,10], + "ash":[2.57,4.67], + "lola":[2.4,8], + "fang":[2.57,3.33], + "janet":[2.4,8.67], + "sam":[2.57,3.33], + "buster":[2.57,6.67], + "chester":[2.4,7.67], + "mandy":[2.4,12], + "maisie":[2.4,8.67], + "cordelius":[2.57,5.67], + "larrylawrie":[2.4,8], + "larryandlawrie":[2.4,8], + "melodie":[2.73,6.67], + "lily":[2.73,3.67], + "angelo":[2.4,10], + "draco":[2.57,3.67], + "berry":[2.4,7.67], + "clancy":[2.4,8], + "moe":[2.57,6], + "kenji":[2.73,3.67], + "juju":[2.4,8.33], + "shade":[2.57,4], + "ollie":[2.57,4.67], + "meeple":[2.4,8.67], "mico":[2.73,4] -} \ No newline at end of file +} diff --git a/constants.py b/constants.py index 57780e3..33ef019 100644 --- a/constants.py +++ b/constants.py @@ -1,6 +1,19 @@ import json +import re +from pathlib import Path from modules.print import bcolors -brawler_stats_dict = json.load(open("brawler_stats.json")) + +REPO_ROOT = Path(__file__).resolve().parent +with open(REPO_ROOT / "brawler_stats.json", encoding="utf-8") as stats_file: + brawler_stats_dict = json.load(stats_file) + + +def normalize_brawler_name(name): + """ + Normalize user-provided brawler names for robust lookup. + e.g. "Mr. P", "MrP", "mr p" -> "mrp" + """ + return re.sub(r"[^a-z0-9]+", "", name.lower().strip()) class Constants: #! Brawler's stats @@ -12,10 +25,13 @@ class Constants: """ go to https://pixelcrux.com/Brawl_Stars/Brawlers/ to find your - brawler's speed and attack range and use hsf_finder.py - to get the brawler's height scale factor - - eg. eve's speed (2.4), attack_range (9.33) and heightScaleFactor (0.158) + brawler's speed and attack range. + + heightScaleFactor is no longer required — player position is now + estimated automatically from the detection bounding box. + It is kept here for backward compatibility but ignored by the detector. + + eg. eve's speed (2.4) and attack_range (9.33) """ speed = 2.4 # units: (tiles per second) attack_range = 9.33 # units: (tiles) @@ -29,6 +45,56 @@ class Constants: """ sharpCorner = True centerOrder = True + + #! Gameplay mode profile + """ + Supported: + - solo_showdown + - team_3v3 + - team_5v5 + """ + game_mode = "solo_showdown" + game_mode_profiles = { + "solo_showdown": { + "hide_in_bush": True, + "centerOrder": True, + "aggression": 1.0, + "prediction_seconds": 0.35, + "teammate_support_range": 6, + "team_aggression_distance_multiplier": 0.9, + "search_priority": ["Bush", "Cubebox", "Enemy"], + "objective_move_cap_seconds": 2.6, + }, + "team_3v3": { + "hide_in_bush": False, + "centerOrder": False, + "aggression": 1.15, + "prediction_seconds": 0.35, + "teammate_support_range": 6, + "team_aggression_distance_multiplier": 0.9, + "search_priority": ["Enemy", "Bush", "Cubebox"], + "objective_move_cap_seconds": 1.4, + }, + "team_5v5": { + "hide_in_bush": False, + "centerOrder": False, + "aggression": 1.2, + "prediction_seconds": 0.4, + "teammate_support_range": 7, + "team_aggression_distance_multiplier": 0.88, + "search_priority": ["Enemy", "Bush", "Cubebox"], + "objective_move_cap_seconds": 1.2, + }, + } + + #! Rank pushing context (manual) + """ + This is used for rank push context/output. + Current and target rank are intentionally manual values. + """ + rank_push_enabled = False + current_rank = None + target_rank = None #! Window Capture """ @@ -52,16 +118,29 @@ class Constants: #! Do not change these # Detector constants - classes = ["Player","Bush","Enemy","Cubebox"] - """ - Threshold's index correspond with classes's index. - e.g. First element of classes is player so the first - element of threshold is threshold for player. - """ - threshold = [0.37,0.47,0.57,0.65] + classes = ["Player", "Bush", "Enemy", "Cubebox", "Teammate"] + class_threshold = { + "Player": 0.37, + "Bush": 0.47, + "Enemy": 0.57, + "Cubebox": 0.65, + "Teammate": 0.57, + } + default_class_threshold = min(class_threshold.values()) + # Backward-compatible index-based thresholds for any existing code paths. + # This list intentionally follows only the `classes` array order. + threshold = [class_threshold.get(class_name, default_class_threshold) for class_name in classes] + + normalized_game_mode = game_mode.lower().strip() + if normalized_game_mode not in game_mode_profiles: + print(bcolors.WARNING + f"Unknown game_mode '{game_mode}', defaulting to solo_showdown." + bcolors.ENDC) + normalized_game_mode = "solo_showdown" + active_game_mode = normalized_game_mode + selected_game_mode = game_mode_profiles[active_game_mode] + centerOrder = selected_game_mode["centerOrder"] try: - brawler_stats = brawler_stats_dict[brawler_name.lower().strip()] + brawler_stats = brawler_stats_dict[normalize_brawler_name(brawler_name)] display_str = f"Using {brawler_name.upper()}'s stats if your selected brawler is not {brawler_name.upper()},\nplease manually modify at constants.py." standard_hsf = 0.15 if len(brawler_stats) == 2: @@ -71,7 +150,7 @@ class Constants: brawler_stats = 3*[None] except KeyError: brawler_stats = 3*[None] - display_str = f"{brawler_name.upper()}'s stats is not found in the JSON. \nUsing speed, attack_range and heightScaleFactor in constant.py.\nPlease manually modify at constants.py if you have not." + display_str = f"{brawler_name.upper()}'s stats are not found in the JSON. \nUsing speed, attack_range and heightScaleFactor in constants.py.\nPlease manually modify at constants.py if you have not." print("") print(bcolors.BOLD + bcolors.OKGREEN + "Original Creator: https://github.com/Jooi025/BrawlStarsBot" + bcolors.ENDC) print("") @@ -120,4 +199,4 @@ class Constants: assert type(bool_dict[key]) == bool,f"{key.upper()} should be True or False" if __name__ == "__main__": - pass \ No newline at end of file + pass diff --git a/detection_test.py b/detection_test.py index 7fd61fe..12ca349 100644 --- a/detection_test.py +++ b/detection_test.py @@ -12,7 +12,7 @@ wincap.set_window() # initialize detection class -detector = Detection(windowSize,Constants.model_file_path,Constants.classes,Constants.heightScaleFactor) +detector = Detection(windowSize,Constants.model_file_path,Constants.classes,Constants.heightScaleFactor,Constants.class_threshold) wincap.start() detector.start() @@ -37,4 +37,4 @@ detector.stop() cv.destroyAllWindows() break -print('Done.') \ No newline at end of file +print('Done.') diff --git a/main.py b/main.py index 2998ce6..8d6cb24 100644 --- a/main.py +++ b/main.py @@ -37,7 +37,13 @@ def main(): wincap.set_window() # initialize detection class - detector = Detection(windowSize,Constants.model_file_path,Constants.classes,Constants.heightScaleFactor) + detector = Detection( + windowSize, + Constants.model_file_path, + Constants.classes, + Constants.heightScaleFactor, + Constants.class_threshold + ) # initialize screendectect class screendetect = Screendetect(windowSize,wincap.offsets) # initialize bot class @@ -55,6 +61,9 @@ def main(): print(f"Resolution: {wincap.screen_resolution}") print(f"Window Size: {windowSize}") print(f"Scaling: {wincap.scaling*100}%") + print(f"Mode: {Constants.active_game_mode}") + if Constants.rank_push_enabled: + print(f"Rank push: current={Constants.current_rank}, target={Constants.target_rank}") aspect_ratio = windowSize[0]/windowSize[1] if aspect_ratio > 1.79: @@ -63,6 +72,7 @@ def main(): while True: screenshot = wincap.screenshot if screenshot is None: + sleep(0.001) continue # update screenshot for dectector detector.update(screenshot) @@ -127,7 +137,7 @@ def main(): if __name__ == "__main__": print(" ") print(bcolors.HEADER + bcolors.BOLD + - "Before starting the bot, make sure you have Brawl Stars open \non Bluestacks and selected solo showdown gamemode.") + "Before starting the bot, make sure you have Brawl Stars open \non Bluestacks and selected your intended game mode.") print("") print("Also make sure to change the speed, attack_range and HeightScaleFactor" +"\nfor you selected brawler at constants.py (instruction there as well).") @@ -162,4 +172,4 @@ def main(): # exit elif user_input =="4" or user_input == "exit": print("Exitting...") - break \ No newline at end of file + break diff --git a/misc/textInstruction.md b/misc/textInstruction.md index 32dc168..6841356 100644 --- a/misc/textInstruction.md +++ b/misc/textInstruction.md @@ -3,9 +3,9 @@ ### Testing and changing values **Important - please disable Bluestacks' ads and close the left sidebar for the bot to work as intended** 1. Run "detection_test.py" to check if object detection is working -2. Change the brawler_name in "constants.py" to your selected Brawler's name and run "constant.py". +2. Change the brawler_name in "constants.py" to your selected brawler's name and run "constants.py" (spaces/punctuation are handled, e.g. "Mr. P" or "mr p"). 3. If the brawler's stats in not found manually change the speed, attack range and height scale factor located below brawler_name at "constant.py" to the brawler's [speed and range](https://pixelcrux.com/Brawl_Stars/Brawlers/) and to find the height scale factor run "hsf_finder". Also modify sharpCorner (True if the map has many walls, otherwise False) and centerOrder ( True if brawler spawns in the middle of the map, otherwise False). 4. Run "main.py" -5. Select solo showdown and "start bot" (enter 1) +5. Select your configured mode in-game (matching `game_mode` in `constants.py`) and "start bot" (enter 1). Solo profiles prioritize bush/cubebox play; team profiles prioritize enemy repositioning. diff --git a/modules/bot.py b/modules/bot.py index 55d9122..7b37f12 100644 --- a/modules/bot.py +++ b/modules/bot.py @@ -1,15 +1,15 @@ from time import time,sleep from threading import Thread, Lock from math import * +from collections import deque import pyautogui as py import numpy as np -import random from constants import Constants """ INITIALIZING: Initialize the bot -SEARCHING: Find the nearby bush to player -MOVING: Move to the selected bush -HIDING: Stop movement and hide in the bush +SEARCHING: Find the next objective based on mode profile (bush, cube box, enemy) +MOVING: Move to the selected objective +HIDING: Stop movement and hide in the bush (solo-focused profiles) ATTACKING: Player will attack and activate gadget when enemy is nearby """ @@ -53,7 +53,39 @@ class Brawlbot: def __init__(self,windowSize,offsets,speed,attack_range) -> None: self.lock = Lock() - + self.class_index = {name: i for i, name in enumerate(Constants.classes)} + self.player_index = self.class_index.get("Player") + self.bush_index = self.class_index.get("Bush") + self.enemy_index = self.class_index.get("Enemy") + self.cubebox_index = self.class_index.get("Cubebox") + self.teammate_index = self.class_index.get("Teammate") + self.game_mode = Constants.active_game_mode + self.mode_profile = Constants.selected_game_mode + self.team_mode = self.game_mode in ("team_3v3", "team_5v5") + self.centerOrder = self.mode_profile["centerOrder"] + self.enemy_prediction_seconds = self.mode_profile["prediction_seconds"] + self.aggression = self.mode_profile["aggression"] + self.hide_in_bush = self.mode_profile["hide_in_bush"] + self.teammate_support_range = self.mode_profile["teammate_support_range"] + self.team_aggression_distance_multiplier = self.mode_profile["team_aggression_distance_multiplier"] + self.search_priority = self.mode_profile.get("search_priority", ["Bush", "Cubebox", "Enemy"]) + self.objective_move_cap_seconds = self.mode_profile.get("objective_move_cap_seconds") + self.rank_push_enabled = Constants.rank_push_enabled + self.current_rank = Constants.current_rank + self.target_rank = Constants.target_rank + self.enemy_history = deque(maxlen=2) + # Deterministic fallback order reduces erratic movement and makes behavior reproducible. + self.fallback_directions = ("w", "a", "s", "d") + self.fallback_index = 0 + self.last_attack_time = 0 + self.last_gadget_time = 0 + self.last_enemy_seen_timestamp = None + self.attack_cooldown_seconds = 0.25 + self.gadget_cooldown_seconds = 2.0 + self.enemy_lost_grace_seconds = 0.45 + if self.rank_push_enabled: + print(f"Rank push enabled: current={self.current_rank}, target={self.target_rank}") + # "brawler" chracteristic self.speed = speed # short range @@ -75,6 +107,9 @@ def __init__(self,windowSize,offsets,speed,attack_range) -> None: self.gadget_range = 0.9*self.attack_range self.hide_attack_range = 3.5 # visible to enemy in the bush self.HIDINGTIME = hide_multiplier * 23 + # Team modes require faster regrouping and less passive bush time. + if self.team_mode: + self.HIDINGTIME = min(self.HIDINGTIME, 6) self.timestamp = time() self.window_w = windowSize[0] @@ -90,11 +125,70 @@ def __init__(self,windowSize,offsets,speed,attack_range) -> None: self.offset_x = offsets[0] self.offset_y = offsets[1] - #index - self.player_index = 0 - self.bush_index = 1 - self.enemy_index = 2 - + def _safe_results(self, index): + if index is None or not self.results: + return [] + if index < 0 or index >= len(self.results): + return [] + return self.results[index] + + def _player_position(self): + player_detections = self._safe_results(self.player_index) + if player_detections: + return player_detections[0] + return self.center_window + + def _teammate_in_support_range(self): + teammate_detections = self._safe_results(self.teammate_index) + if not teammate_detections: + return False + player_pos = self._player_position() + distances = [self.tile_distance(player_pos, teammate) for teammate in teammate_detections] + if not distances: + return False + closest_teammate = min(distances) + return closest_teammate <= self.teammate_support_range + + def _update_enemy_history(self, enemy_pos): + now = time() + self.enemy_history.append((now, enemy_pos)) + + def _predict_enemy_position(self): + if len(self.enemy_history) < 2: + return None + (t0, p0), (t1, p1) = self.enemy_history + dt = t1 - t0 + if dt <= 0: + return None + vx = (p1[0] - p0[0]) / dt + vy = (p1[1] - p0[1]) / dt + predicted_x = p1[0] + vx * self.enemy_prediction_seconds + predicted_y = p1[1] + vy * self.enemy_prediction_seconds + return (predicted_x, predicted_y) + + def _next_fallback_direction(self): + """ + Return the next fallback movement key using a deterministic direction cycle. + """ + key = self.fallback_directions[self.fallback_index] + self.fallback_index = (self.fallback_index + 1) % len(self.fallback_directions) + return key + + def _normalize_move_key(self, move_keys): + """ + Normalize movement input into a single key string. + Accepts a single key or list of keys and falls back to a deterministic + direction cycle when no valid key is available. + """ + if isinstance(move_keys, str) and move_keys: + if move_keys in self.fallback_directions: + return move_keys + return self._next_fallback_direction() + if isinstance(move_keys, list): + filtered = [key for key in move_keys if key in self.fallback_directions] + if filtered: + return filtered[0] + return self._next_fallback_direction() # translate a pixel position on a screenshot image to a pixel position on the screen. # pos = (x, y) @@ -125,13 +219,14 @@ def guess_storm_direction(self): # if there is a detection if self.results: # there player detection - if self.results[self.player_index]: + player_detections = self._safe_results(self.player_index) + if player_detections: x_border = (self.window_w/self.tile_w)*self.border_size y_border = (self.window_h/self.tile_h)*self.border_size # coordinate of the middle of the screen p0 = self.center_window # coordinate of the player - p1 = self.results[self.player_index][0] + p1 = player_detections[0] # get the difference between centre and the player xDiff , yDiff = tuple(np.subtract(p1, p0)) # player is on the right @@ -163,7 +258,7 @@ def storm_movement_key(self): # if there is detection if self.results: # if there is player detection - if self.results[self.player_index]: + if self._safe_results(self.player_index): # predict the storm direction direction = self.guess_storm_direction() if direction[0] == self.direction[2]: @@ -230,15 +325,15 @@ def ordered_bush_by_distance(self, index): # our character is always in the center of the screen # if player position in result is empty # assume that player is in the middle of the screen - if not(self.results[self.player_index]) or self.centerOrder: + if self.centerOrder: player_position = self.center_window else: - player_position = self.results[self.player_index][0] + player_position = self._player_position() def tile_distance(position): return sqrt(((position[0] - player_position[0])/(self.window_w/self.tile_w))**2 + ((position[1] - player_position[1])/(self.window_h/self.tile_h))**2) # list of bush location is the in index 1 of results - unfilteredResults = self.results[index] + unfilteredResults = self._safe_results(index) filteredResult = [] # get quadrant quadrant = self.get_quadrant_bush() @@ -258,20 +353,17 @@ def tile_distance(position): unfilteredResults.sort(key=tile_distance) return unfilteredResults - def ordered_enemy_by_distance(self,index): - # our character is always in the center of the screen - # if player position in result is empty - # assume that player is in the middle of the screen - if not(self.results[self.player_index]): - player_position = self.center_window - else: - player_position = self.results[self.player_index][0] + def ordered_results_by_distance(self, index): + """ + Sort detections for any class by distance from the player. + """ + player_position = self._player_position() def tile_distance(position): - return sqrt(((position[0] - player_position[0])/(self.window_w/self.tile_w))**2 + return sqrt(((position[0] - player_position[0])/(self.window_w/self.tile_w))**2 + ((position[1] - player_position[1])/(self.window_h/self.tile_h))**2) - sortedResults = self.results[index] - sortedResults.sort(key=tile_distance) - return sortedResults + sorted_results = list(self._safe_results(index)) + sorted_results.sort(key=tile_distance) + return sorted_results def tile_distance(self,player_position,position): """ @@ -306,10 +398,7 @@ def move_to_bush(self): # else: # index = 0 x,y = self.bushResult[0] - if not(self.results[self.player_index]): - player_pos = self.center_window - else: - player_pos = self.results[self.player_index][0] + player_pos = self._player_position() tileDistance = self.tile_distance(player_pos,(x,y)) x,y = self.get_screen_position((x,y)) py.mouseDown(button=Constants.movement_key,x=x, y=y) @@ -317,23 +406,70 @@ def move_to_bush(self): moveTime = moveTime * self.timeFactor print(f"Distance: {round(tileDistance,2)} tiles") return moveTime + + def move_to_target(self, target_position, max_move_time=None): + """ + Move toward a target detection position. + """ + if target_position is None: + return None + x, y = target_position + player_pos = self._player_position() + tileDistance = self.tile_distance(player_pos, (x, y)) + x, y = self.get_screen_position((x, y)) + py.mouseDown(button=Constants.movement_key, x=x, y=y) + moveTime = (tileDistance / self.speed) * self.timeFactor + if max_move_time is not None: + moveTime = min(moveTime, max_move_time) + print(f"Distance: {round(tileDistance,2)} tiles") + return moveTime + + def acquire_objective(self): + """ + Select a movement objective by profile priority. + """ + for class_name in self.search_priority: + class_index = self.class_index.get(class_name) + if class_index is None: + continue + ordered = self.ordered_results_by_distance(class_index) + if ordered: + move_time = self.move_to_target( + ordered[0], + max_move_time=self.objective_move_cap_seconds + ) + if move_time is not None: + return class_name, move_time + return None, None # enemy and attack method def attack(self): """ Press the attack key + :return: True when an attack key press is executed, False when blocked by cooldown. """ + now = time() + if now - self.last_attack_time < self.attack_cooldown_seconds: + return False print("attacking enemy") attack_key = "e" py.press(attack_key) + self.last_attack_time = now + return True def gadget(self): """ Press the gadget key + :return: True when gadget key press is executed, False when blocked by cooldown. """ + now = time() + if now - self.last_gadget_time < self.gadget_cooldown_seconds: + return False print("activate gadget") gadget_key = "f" py.press(gadget_key) + self.last_gadget_time = now + return True def hold_movement_key(self,key,time): """ @@ -347,25 +483,20 @@ def hold_movement_key(self,key,time): def storm_random_movement(self): """ - get movement keys and pick a random key to hold for one second + Get storm-escape movement keys and hold a deterministic fallback key for one second. """ - if self.storm_movement_key(): - move_keys = self.storm_movement_key() - else: - move_keys = ["w", "a", "s", "d"] - random_move = random.choice(move_keys) + move_keys = self.storm_movement_key() + move_key = self._normalize_move_key(move_keys) hold_time = 1 - self.hold_movement_key(random_move,hold_time) + self.hold_movement_key(move_key,hold_time) def stuck_random_movement(self): """ - get movement keys and pick a random key to hold for one second + Get unstuck movement keys and hold a deterministic fallback key for one second. """ move_keys = self.get_movement_key(self.bush_index) - if not(move_keys): - move_keys = ["w", "a", "s", "d"] - move_keys = random.choice(move_keys) - with py.hold(move_keys): + move_key = self._normalize_move_key(move_keys) + with py.hold(move_key): sleep(1) def get_movement_key(self,index): @@ -377,13 +508,8 @@ def get_movement_key(self,index): x_key = "" y_key = "" if self.results: - if self.results[self.player_index]: - player_pos = self.results[self.player_index][0] - # if player position in result is empty - # assume that player is in the middle of the screen - else: - player_pos = self.center_window - if self.results[index]: + player_pos = self._player_position() + if self._safe_results(index): # enemy index if index == self.enemy_index: p0 = self.enemyResults[0] @@ -406,18 +532,16 @@ def get_movement_key(self,index): return [x_key,y_key] return [] - def enemy_random_movement(self): + def enemy_fallback_movement(self): """ Move player away from the enemy and attack """ if not(self.enemy_move_key): move_keys = self.get_movement_key(self.enemy_index) - if not(move_keys): - move_keys = ["w", "a", "s", "d"] - move_keys = random.choice(move_keys) + move_key = self._normalize_move_key(move_keys) else: - move_keys = self.enemy_move_key - with py.hold(move_keys): + move_key = self._normalize_move_key(self.enemy_move_key) + with py.hold(move_key): py.press("e",presses=2,interval=0.4) def enemy_distance(self): @@ -426,17 +550,15 @@ def enemy_distance(self): """ if self.results: # player coordinate - if self.results[self.player_index]: - player_pos = self.results[self.player_index][0] - # if player position in result is empty - # assume that player is in the middle of the screen - else: - player_pos = self.center_window + player_pos = self._player_position() # enemy coordinate - if self.results[self.enemy_index]: - self.enemyResults = self.ordered_enemy_by_distance(self.enemy_index) + if self._safe_results(self.enemy_index): + self.enemyResults = self.ordered_results_by_distance(self.enemy_index) if self.enemyResults: - enemyDistance = self.tile_distance(player_pos,self.enemyResults[0]) + closest_enemy = self.enemyResults[0] + self._update_enemy_history(closest_enemy) + self.last_enemy_seen_timestamp = time() + enemyDistance = self.tile_distance(player_pos, closest_enemy) # print(f"Closest enemy: {round(enemyDistance,2)} tiles") return enemyDistance return None @@ -448,20 +570,47 @@ def is_enemy_in_range(self): """ enemyDistance = self.enemy_distance() if enemyDistance: + if self.team_mode and self._teammate_in_support_range(): + # Slightly increase aggression in team fights with nearby support. + enemyDistance = enemyDistance * self.team_aggression_distance_multiplier + predicted_enemy = self._predict_enemy_position() + if predicted_enemy: + predicted_distance = self.tile_distance(self._player_position(), predicted_enemy) + # Prefer the closest threat estimate to avoid delayed reactions. + enemyDistance = min(enemyDistance, predicted_distance) + effective_attack_range = self.attack_range * self.aggression + effective_gadget_range = self.gadget_range * self.aggression # ranges in tiles - if (enemyDistance > self.attack_range + if (enemyDistance > effective_attack_range and enemyDistance <= self.alert_range): self.enemy_move_key = self.get_movement_key(self.enemy_index) - elif (enemyDistance > self.gadget_range - and enemyDistance <= self.attack_range): + elif (enemyDistance > effective_gadget_range + and enemyDistance <= effective_attack_range): self.attack() return True - elif enemyDistance <= self.gadget_range: + elif enemyDistance <= effective_gadget_range: self.gadget() self.attack() return True return False + def is_cubebox_in_range(self): + """ + Check if a power cubebox is within attack range and attack it to collect it. + :return (boolean): True if a cubebox is in range and was attacked. + """ + cubebox_detections = self._safe_results(self.cubebox_index) + if not cubebox_detections: + return False + player_pos = self._player_position() + distances = [(self.tile_distance(player_pos, pos), pos) for pos in cubebox_detections] + dist, _ = min(distances, key=lambda d: d[0]) + if dist <= self.attack_range: + print("Attacking cubebox") + self.attack() + return True + return False + def is_enemy_close(self): """ Check if enemy is visible in the bush @@ -501,8 +650,9 @@ def have_stopped_moving(self): :return (boolean): True or False """ if self.results: - if self.results[self.player_index]: - player_pos = self.results[self.player_index][0] + player_detections = self._safe_results(self.player_index) + if player_detections: + player_pos = player_detections[0] if self.last_player_pos is None: self.last_player_pos = player_pos else: @@ -574,25 +724,25 @@ def run(self): self.lock.release() elif self.state == BotState.SEARCHING: - success = self.find_bush() - #if bush is detected - if success: - print("found bush") - self.moveTime = self.move_to_bush() + if self.is_enemy_in_range(): + self.lock.acquire() + self.state = BotState.ATTACKING + self.lock.release() + continue + + self.is_cubebox_in_range() + + objective, move_time = self.acquire_objective() + if objective and move_time is not None: + print(f"Moving to {objective.lower()}") + self.moveTime = move_time self.lock.acquire() self.timestamp = time() self.state = BotState.MOVING self.lock.release() - #bush is not detected else: - print("Cannot find bush") + print("Cannot find objective") self.storm_random_movement() - # self.counter+=1 - - if self.is_enemy_in_range(): - self.lock.acquire() - self.state = BotState.ATTACKING - self.lock.release() elif self.state == BotState.MOVING: # when player is moving check if player is stuck @@ -612,17 +762,27 @@ def run(self): self.lock.acquire() self.state = BotState.ATTACKING self.lock.release() + # attack cubeboxes in range while moving toward objective + self.is_cubebox_in_range() # player successfully travel to the selected bush if time() > self.timestamp + self.moveTime: py.mouseUp(button = Constants.movement_key) - print("Hiding") self.lock.acquire() - # change state to hiding self.timestamp = time() - self.state = BotState.HIDING + if self.hide_in_bush: + print("Hiding") + self.state = BotState.HIDING + else: + print("Reposition complete") + self.state = BotState.SEARCHING self.lock.release() elif self.state == BotState.HIDING: + if not self.hide_in_bush: + self.lock.acquire() + self.state = BotState.SEARCHING + self.lock.release() + continue if time() > self.timestamp + self.HIDINGTIME or self.is_player_damaged(): print("Changing state to search") self.lock.acquire() @@ -643,11 +803,15 @@ def run(self): self.lock.release() elif self.state == BotState.ATTACKING: if self.is_enemy_in_range(): - self.enemy_random_movement() + self.enemy_fallback_movement() else: - self.lock.acquire() - self.state = BotState.SEARCHING - self.lock.release() + if (self.last_enemy_seen_timestamp is not None + and time() - self.last_enemy_seen_timestamp <= self.enemy_lost_grace_seconds): + self.enemy_fallback_movement() + else: + self.lock.acquire() + self.state = BotState.SEARCHING + self.lock.release() self.fps = (1 / (time() - self.loop_time)) self.loop_time = time() @@ -655,4 +819,4 @@ def run(self): if self.count == 1: self.avg_fps = self.fps else: - self.avg_fps = (self.avg_fps*self.count+self.fps)/(self.count + 1) \ No newline at end of file + self.avg_fps = (self.avg_fps*self.count+self.fps)/(self.count + 1) diff --git a/modules/detection.py b/modules/detection.py index 4b24974..796ca68 100644 --- a/modules/detection.py +++ b/modules/detection.py @@ -1,5 +1,5 @@ from threading import Thread, Lock -from time import time +from time import time, sleep import cv2 as cv from constants import Constants from ultralytics import YOLO @@ -17,8 +17,10 @@ class Detection: player_topleft = None player_bottomright = None midpoint_offset = Constants.midpoint_offset + frame_id = 0 + processed_frame_id = -1 - def __init__(self, windowSize, model_file_path, classes, heightScaleFactor): + def __init__(self, windowSize, model_file_path, classes, heightScaleFactor, class_thresholds): """ Constructor for the Detection class """ @@ -27,10 +29,13 @@ def __init__(self, windowSize, model_file_path, classes, heightScaleFactor): # load the trained model self.model = YOLO(model_file_path,task="detect") self.classes = classes + self.class_to_index = {name: i for i, name in enumerate(classes)} + self.class_thresholds = class_thresholds self.windowSize = windowSize self.w = windowSize[0] self.h = windowSize[1] - self.height = heightScaleFactor * self.h + # heightScaleFactor is no longer used; player position is now estimated + # automatically from the detection bounding box (see run method). def find_midpoint(self,x1,y1,x2,y2): #x2 > x1 @@ -113,9 +118,9 @@ def update(self, screenshot): """ update screen for detection """ - self.lock.acquire() - self.screenshot = screenshot - self.lock.release() + with self.lock: + self.screenshot = screenshot + self.frame_id += 1 def start(self): """ @@ -124,8 +129,7 @@ def start(self): self.stopped = False self.loop_time = time() self.count = 0 - t = Thread(target=self.run) - t.setDaemon(True) + t = Thread(target=self.run, daemon=True) t.start() def stop(self): @@ -136,39 +140,58 @@ def stop(self): def run(self): while not self.stopped: - if not self.screenshot is None: + with self.lock: + screenshot = self.screenshot + frame_id = self.frame_id + if screenshot is not None and frame_id != self.processed_frame_id: # create empty nested list - tempList = len(self.classes)*[[]] - results = self.model.predict(self.screenshot, imgsz=Constants.imgsz, - half=Constants.half, verbose=False) + tempList = [[] for _ in range(len(self.classes))] + results = self.model.predict( + screenshot, + imgsz=Constants.imgsz, + half=Constants.half, + conf=min(Constants.threshold), + verbose=False + ) result = results[0] for box in result.boxes: x1, y1, x2, y2 = [round(x) for x in box.xyxy[0].tolist()] class_id = int(box.cls[0].item()) prob = round(box.conf[0].item(), 2) - threshold = Constants.threshold[class_id] + class_name = result.names.get(class_id) + if class_name is None: + continue + if class_name not in self.class_to_index: + continue + threshold = self.class_thresholds.get(class_name, min(Constants.threshold)) if prob >= threshold: + target_index = self.class_to_index[class_name] midpoint = self.find_midpoint(x1,y1,x2,y2) - if self.classes[class_id] == "Player": + if class_name == "Player": # Constantly update player name tag position to check if # player is damaged in bot module while in hiding state self.player_topleft = (x1,y1) self.player_bottomright = (x2,y2) - midpoint = [( midpoint[0][0], int(midpoint[0][1] + self.height))] - if self.classes[class_id] == "Enemy": + # Auto-estimate brawler ground position from the bottom of the + # detection bounding box, eliminating the need for manual + # HeightScaleFactor (HSF) calibration. + midpoint = [(midpoint[0][0], y2)] + if class_name == "Enemy": #standardised enemy height and their label enemy_height = y2 - y1 y1 = y1 + (enemy_height+0.2*self.h) midpoint = [( midpoint[0][0], int(midpoint[0][1] + 0.05*self.h))] - tempList[class_id] = tempList[class_id] + midpoint + tempList[target_index].extend(midpoint) # lock the thread while updating the results - self.lock.acquire() - self.results = tempList - self.lock.release() + with self.lock: + self.results = tempList + self.processed_frame_id = frame_id self.fps = (1 / (time() - self.loop_time)) self.loop_time = time() self.count += 1 if self.count == 1: self.avg_fps = self.fps else: - self.avg_fps = (self.avg_fps*self.count+self.fps)/(self.count + 1) \ No newline at end of file + self.avg_fps = (self.avg_fps*self.count+self.fps)/(self.count + 1) + else: + sleep(0.001) diff --git a/modules/screendetect.py b/modules/screendetect.py index 1a89469..c74a3cf 100644 --- a/modules/screendetect.py +++ b/modules/screendetect.py @@ -5,7 +5,7 @@ import pyautogui as py from threading import Thread, Lock -from time import sleep +from time import sleep, time from constants import Constants """ @@ -54,10 +54,21 @@ def __init__(self,windowSize,offset) -> None: """ self.state = Detectstate.DETECT self.lock = Lock() + # Start safe by assuming the bot is stopped until main loop updates this flag. + self.bot_stopped = True self.w = windowSize[0] self.h = windowSize[1] self.offset_x = offset[0] self.offset_y = offset[1] + self.last_action_time = {} + self.action_cooldowns = { + Detectstate.PLAY_AGAIN: 1.0, + Detectstate.LOAD: 1.0, + Detectstate.EXIT: 2.0, + Detectstate.PLAY: 1.0, + Detectstate.PROCEED: 1.0, + Detectstate.STARDROP: 5.0, + } # Coordinate self.defeated1 = (round(self.w*0.9656)+self.offset_x, round(self.h*0.152)+self.offset_y) @@ -77,14 +88,32 @@ def __init__(self,windowSize,offset) -> None: def update_bot_stop(self,bot_stopped): self.bot_stopped = bot_stopped + + def set_state(self, state): + with self.lock: + self.state = state + + def _state_ready(self, state): + now = time() + last = self.last_action_time.get(state, 0) + cooldown = self.action_cooldowns.get(state, 0) + if now - last < cooldown: + return False + self.last_action_time[state] = now + return True + + def _pixel_match(self, coordinate, color, tolerance): + try: + return py.pixelMatchesColor(coordinate[0], coordinate[1], color, tolerance=tolerance) + except OSError: + return False def start(self): """ start screendetect """ self.stopped = False - t = Thread(target=self.run) - t.setDaemon(True) + t = Thread(target=self.run, daemon=True) t.start() def stop(self): @@ -97,75 +126,53 @@ def run(self): while not self.stopped: sleep(0.01) if self.state == Detectstate.IDLE: - sleep(3) + # Balance responsiveness and CPU usage while waiting for next detect cycle. + sleep(1.0) self.state = Detectstate.DETECT elif self.state == Detectstate.DETECT: - try: - if py.pixelMatchesColor(self.playAgainButton[0], self.playAgainButton[1],self.playColor,tolerance=15): + if self._pixel_match(self.playAgainButton, self.playColor, tolerance=15): + if self._state_ready(Detectstate.PLAY_AGAIN): print("Playing again") - self.lock.acquire() - self.state = Detectstate.PLAY_AGAIN - self.lock.release() - - elif py.pixelMatchesColor(self.loadButton[0], self.loadButton[1],self.loadColor,tolerance=30): + self.set_state(Detectstate.PLAY_AGAIN) + + elif self._pixel_match(self.loadButton, self.loadColor, tolerance=30): + if self._state_ready(Detectstate.LOAD): print("Loading in") - self.lock.acquire() - sleep(3) - self.state = Detectstate.LOAD - self.lock.release() - - elif (py.pixelMatchesColor(self.defeated1[0], self.defeated1[1], - self.defeatedColor,tolerance=15) - or py.pixelMatchesColor(self.defeated2[0], self.defeated2[1], - self.defeatedColor,tolerance=15)) and not(self.bot_stopped): + self.set_state(Detectstate.LOAD) + + elif (self._pixel_match(self.defeated1, self.defeatedColor, tolerance=15) + or self._pixel_match(self.defeated2, self.defeatedColor, tolerance=15)) and not(self.bot_stopped): + if self._state_ready(Detectstate.EXIT): print("Exiting match") - self.lock.acquire() - self.state = Detectstate.EXIT - self.lock.release() - - # elif pyautogui.pixelMatchesColor(self.connection_lost_cord[0],self.connection_lost_cord[1],self.connection_lost_color,tolerance=1): - # print("Connection Lost") - # self.lock.acquire() - # self.state = Detectstate.CONNECTION - # self.lock.release() - - elif (py.pixelMatchesColor(self.starDrop1[0], self.starDrop1[1], self.starDropColor,tolerance=15) - or py.pixelMatchesColor(self.starDrop2[0], self.starDrop2[1], self.starDropColor,tolerance=15)): + self.set_state(Detectstate.EXIT) + + elif (self._pixel_match(self.starDrop1, self.starDropColor, tolerance=15) + or self._pixel_match(self.starDrop2, self.starDropColor, tolerance=15)): + if self._state_ready(Detectstate.STARDROP): print("Collecting Star Drop") - self.lock.acquire() - self.state = Detectstate.STARDROP - self.lock.release() - - elif py.pixelMatchesColor(self.playButton[0], self.playButton[1], self.playColor, tolerance=15): + self.set_state(Detectstate.STARDROP) + + elif self._pixel_match(self.playButton, self.playColor, tolerance=15): + if self._state_ready(Detectstate.PLAY): print("Play") - self.lock.acquire() - self.state = Detectstate.PLAY - self.lock.release() + self.set_state(Detectstate.PLAY) - elif py.pixelMatchesColor(self.proceedButton[0], self.proceedButton[1], self.proceedColor, tolerance=25): + elif self._pixel_match(self.proceedButton, self.proceedColor, tolerance=25): + if self._state_ready(Detectstate.PROCEED): print("Proceed") - self.lock.acquire() - self.state = Detectstate.PROCEED - self.lock.release() - - except OSError: - pass + self.set_state(Detectstate.PROCEED) elif self.state == Detectstate.PLAY_AGAIN: # click the play button sleep(0.05) py.click(x=self.playAgainButton[0], y=self.playAgainButton[1], button="left") sleep(0.05) - self.lock.acquire() - self.state = Detectstate.IDLE - self.lock.release() + self.set_state(Detectstate.IDLE) elif self.state == Detectstate.LOAD: sleep(0.1) - self.lock.acquire() - self.state = Detectstate.IDLE - self.lock.release() + self.set_state(Detectstate.IDLE) elif self.state == Detectstate.EXIT: # release movement key @@ -174,39 +181,29 @@ def run(self): # click the exit button py.click(x=self.exitButton[0], y=self.exitButton[1], button="left") sleep(0.05) - self.lock.acquire() - self.state = Detectstate.IDLE - self.lock.release() + self.set_state(Detectstate.IDLE) elif self.state == Detectstate.CONNECTION: sleep(20) py.click(x=self.reload_button[0], y=self.reload_button[1], button="left") sleep(0.05) - self.lock.acquire() - self.state = Detectstate.IDLE - self.lock.release() + self.set_state(Detectstate.IDLE) elif self.state == Detectstate.PLAY: # click the play button sleep(0.05) py.click(x=self.playButton[0], y=self.playButton[1], button="left") sleep(0.05) - self.lock.acquire() - self.state = Detectstate.IDLE - self.lock.release() + self.set_state(Detectstate.IDLE) elif self.state == Detectstate.PROCEED: sleep(0.5) py.click(x=self.proceedButton[0], y=self.proceedButton[1], button="left", clicks=2) sleep(0.5) - self.lock.acquire() - self.state = Detectstate.IDLE - self.lock.release() + self.set_state(Detectstate.IDLE) elif self.state == Detectstate.STARDROP: py.press("e",presses=5) sleep(6) py.press("e") - self.lock.acquire() - self.state = Detectstate.IDLE - self.lock.release() \ No newline at end of file + self.set_state(Detectstate.IDLE) diff --git a/modules/windowcapture.py b/modules/windowcapture.py index 43053b8..0b0d8e4 100644 --- a/modules/windowcapture.py +++ b/modules/windowcapture.py @@ -114,7 +114,7 @@ def get_screenshot(self): # convert the raw data into a format opencv can read #dataBitMap.SaveBitmapFile(cDC, 'debug.bmp') signedIntsArray = dataBitMap.GetBitmapBits(True) - img = np.fromstring(signedIntsArray, dtype='uint8') + img = np.frombuffer(signedIntsArray, dtype=np.uint8) img.shape = (self.h, self.w, 4) # free resources @@ -155,8 +155,7 @@ def start(self): self.stopped = False self.loop_time = time() self.count = 0 - t = Thread(target=self.run) - t.setDaemon(True) + t = Thread(target=self.run, daemon=True) t.start() def stop(self): @@ -170,9 +169,8 @@ def run(self): # get an updated image of the game screenshot = self.get_screenshot() # lock the thread while updating the results - self.lock.acquire() - self.screenshot = screenshot - self.lock.release() + with self.lock: + self.screenshot = screenshot self.fps = (1 / (time() - self.loop_time)) self.loop_time = time() @@ -180,4 +178,4 @@ def run(self): if self.count == 1: self.avg_fps = self.fps else: - self.avg_fps = (self.avg_fps*self.count+self.fps)/(self.count + 1) \ No newline at end of file + self.avg_fps = (self.avg_fps*self.count+self.fps)/(self.count + 1) diff --git a/requirements.txt b/requirements.txt index db8d8f1..182e03f 100644 Binary files a/requirements.txt and b/requirements.txt differ