Загрузка данных
import math
import os
import threading
import tkinter as tk
from tkinter import font as tkfont
import customtkinter as ctk
from PIL import Image, ImageDraw, ImageTk
from bot_logic import BrawlBot
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BRAWLERS_DIR = os.path.join(BASE_DIR, "assets", "brawlers")
UI_DIR = os.path.join(BASE_DIR, "assets", "ui")
COLUMNS = 10
MAX_ICON = 82
MIN_ICON = 30
# Доля каждой стороны иконки, которая обрезается (убирает тёмную рамку,
# нарисованную прямо в PNG). 0.0 = не обрезать, 0.08 = по 8% с каждого края.
ICON_CROP = 0.08
WINDOW_BLUE = "#004cd0"
ENTRY_HEIGHT = 32
LOGO_HEIGHT = 140 # было 70 — логотип увеличен в 2 раза
BG_TOLERANCE = 40 # насколько цвет может отличаться от фона, чтобы считаться фоном
# Шрифты по приоритету: берётся первый установленный в системе
FONT_CANDIDATES = ["Segoe UI", "Nunito", "Trebuchet MS", "Verdana", "Arial"]
FONT_FAMILY = "Arial"
def pick_font_family():
installed = set(tkfont.families())
for family in FONT_CANDIDATES:
if family in installed:
return family
return "Arial"
def remove_background(image, tolerance=BG_TOLERANCE):
"""Убирает однотонный фон (белый/любой по цвету углов) и обрезает картинку
по границам содержимого. Фон ищется заливкой от краёв, поэтому светлые
детали внутри самого изображения не пострадают."""
image = image.convert("RGBA")
width, height = image.size
transparent = (0, 0, 0, 0)
for corner in ((0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1)):
if image.getpixel(corner)[3] == 0:
continue # этот угол уже прозрачный
ImageDraw.floodfill(image, corner, transparent, thresh=tolerance)
# Сглаживание: убираем светлую «окантовку» вдоль границы с прозрачностью
pixels = image.load()
to_clear = []
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
if a == 0 or min(r, g, b) < 200:
continue
for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
if 0 <= nx < width and 0 <= ny < height and pixels[nx, ny][3] == 0:
to_clear.append((x, y))
break
for x, y in to_clear:
pixels[x, y] = transparent
bbox = image.getbbox() # учитывает альфа-канал
if bbox:
image = image.crop(bbox)
return image
def load_ui_image(filename, remove_bg=False):
"""Загружает картинку из assets/ui или возвращает None, если файла нет.
remove_bg=True — вырезает фон и обрезает пустые поля."""
path = os.path.join(UI_DIR, filename)
if not os.path.exists(path):
print(f"Файл не найден: {path}")
return None
image = Image.open(path).convert("RGBA")
if remove_bg:
image = remove_background(image)
return image
class BrawlBotApp(ctk.CTk):
def __init__(self):
super().__init__()
global FONT_FAMILY
FONT_FAMILY = pick_font_family()
self.title("Brawl Bot Controller")
self.geometry("1140x700")
self.minsize(900, 620)
self.configure(fg_color=WINDOW_BLUE)
self.bot = None # создаётся в фоне (EasyOCR грузится долго)
self.busy = False
self.selected_brawler = None
self.brawlers = [] # список (имя, PIL-картинка)
self.icon_photos = [] # PhotoImage текущего размера
self.icon_size = 0
self.scroll_offset = 0
self.max_offset = 0
self.grid_w = 0
self.grid_h = 0
self.cell_w = 1
self.cell_h = 1
self.bg_pil = None
self.bg_resized = None
self._resize_job = None
self.load_brawlers()
self.setup_canvas()
self.setup_grid()
self.setup_inputs()
self.init_bot_async()
self.bind("<Configure>", self.on_configure)
self.after(100, self.relayout)
# ------------------------------------------------------------------
# Загрузка бойцов
# ------------------------------------------------------------------
def load_brawlers(self):
if not os.path.isdir(BRAWLERS_DIR):
print(f"Папка не найдена: {BRAWLERS_DIR}")
return
files = sorted(
f for f in os.listdir(BRAWLERS_DIR) if f.lower().endswith(".png")
)
for filename in files:
name = os.path.splitext(filename)[0]
path = os.path.join(BRAWLERS_DIR, filename)
try:
image = Image.open(path).convert("RGBA")
except Exception as error:
print(f"Не удалось загрузить {filename}: {error}")
continue
width, height = image.size
margin = int(min(width, height) * ICON_CROP)
if margin > 0:
image = image.crop((margin, margin, width - margin, height - margin))
self.brawlers.append((name, image))
# ------------------------------------------------------------------
# Главный холст: фон, текст и звезда рисуются прямо на нём
# ------------------------------------------------------------------
def setup_canvas(self):
bg_path = os.path.join(UI_DIR, "background.png")
if os.path.exists(bg_path):
self.bg_pil = Image.open(bg_path).convert("RGB")
self.canvas = tk.Canvas(
self, highlightthickness=0, bd=0, bg=WINDOW_BLUE
)
self.canvas.place(x=0, y=0, relwidth=1, relheight=1)
self.bg_item = self.canvas.create_image(0, 0, anchor="nw")
self.title_item = self.canvas.create_text(
0, 0,
text="BRAWL BOT CONTROLLER",
font=(FONT_FAMILY, -36, "bold"),
fill="#ffcc00",
)
self.subtitle_item = self.canvas.create_text(
0, 0,
text="ВЫБЕРИТЕ БОЙЦА ДЛЯ ПРОКАЧКИ:",
font=(FONT_FAMILY, -18, "bold"),
fill="#ffffff",
)
self.status_item = self.canvas.create_text(
0, 0,
text="Боец не выбран",
font=(FONT_FAMILY, -16, "italic"),
fill="#ff4500",
)
# Логотип вместо текстового заголовка
self.logo_pil = load_ui_image("logo.png", remove_bg=True)
self.logo_item = self.canvas.create_image(0, 0, anchor="center")
if self.logo_pil is not None:
self.canvas.itemconfigure(self.title_item, state="hidden")
# Кубки рядом с полями ввода
self.trophy_pil = load_ui_image("trophy.png", remove_bg=True)
self.trophy2_pil = load_ui_image("2trophy.png", remove_bg=True)
self.trophy_item = self.canvas.create_image(0, 0, anchor="e")
self.trophy2_item = self.canvas.create_image(0, 0, anchor="w")
self.star_item = None
star_path = os.path.join(UI_DIR, "star.png")
if os.path.exists(star_path):
star_pil = Image.open(star_path).convert("RGBA")
star_pil = star_pil.resize((60, 60), Image.LANCZOS)
self.star_photo = ImageTk.PhotoImage(star_pil)
self.star_item = self.canvas.create_image(
0, 0, anchor="ne", image=self.star_photo
)
# ------------------------------------------------------------------
# Сетка бойцов (свой холст с кусочком фона, прокрутка колесом мыши)
# ------------------------------------------------------------------
def setup_grid(self):
self.grid_canvas = tk.Canvas(
self, highlightthickness=0, bd=0, bg=WINDOW_BLUE
)
self.grid_bg_item = self.grid_canvas.create_image(0, 0, anchor="nw")
self.grid_canvas.bind("<Button-1>", self.on_grid_click)
self.grid_canvas.bind("<MouseWheel>", self.on_mouse_wheel)
self.grid_canvas.bind("<Button-4>", lambda e: self.scroll_by(-40))
self.grid_canvas.bind("<Button-5>", lambda e: self.scroll_by(40))
def render_grid(self):
gw, gh = self.grid_w, self.grid_h
if gw <= 0 or gh <= 0:
return
usable_w = gw - 12
cell_w = usable_w / COLUMNS
icon = max(MIN_ICON, min(MAX_ICON, int(cell_w) - 10))
cell_h = icon + 12
if icon != self.icon_size:
self.icon_size = icon
self.icon_photos = [
ImageTk.PhotoImage(img.resize((icon, icon), Image.LANCZOS))
for _, img in self.brawlers
]
rows = math.ceil(len(self.brawlers) / COLUMNS)
total_h = rows * cell_h
self.cell_w = cell_w
self.cell_h = cell_h
self.max_offset = max(0, total_h - gh)
self.scroll_offset = max(0, min(self.scroll_offset, self.max_offset))
canvas = self.grid_canvas
canvas.delete("icons")
for index, (name, _) in enumerate(self.brawlers):
row = index // COLUMNS
col = index % COLUMNS
cx = col * cell_w + cell_w / 2
cy = row * cell_h + cell_h / 2 - self.scroll_offset
if cy + cell_h / 2 < 0 or cy - cell_h / 2 > gh:
continue
canvas.create_image(
cx, cy, image=self.icon_photos[index], tags="icons"
)
if name == self.selected_brawler:
half = icon / 2 + 2
canvas.create_rectangle(
cx - half, cy - half, cx + half, cy + half,
outline="#ffcc00", width=3, tags="icons",
)
# Тонкий индикатор прокрутки справа
if self.max_offset > 0:
thumb_h = max(30, gh * gh / total_h)
thumb_y = (self.scroll_offset / self.max_offset) * (gh - thumb_h)
canvas.create_rectangle(
gw - 7, thumb_y, gw - 3, thumb_y + thumb_h,
fill="#ffffff", outline="", tags="icons",
)
def scroll_by(self, delta):
if self.max_offset <= 0:
return
self.scroll_offset = max(0, min(self.scroll_offset + delta, self.max_offset))
self.render_grid()
def on_mouse_wheel(self, event):
direction = -1 if event.delta > 0 else 1
self.scroll_by(direction * 40)
def on_grid_click(self, event):
if event.x >= self.grid_w - 12:
return
col = int(event.x // self.cell_w)
row = int((event.y + self.scroll_offset) // self.cell_h)
if col < 0 or col >= COLUMNS or row < 0:
return
index = row * COLUMNS + col
if index < len(self.brawlers):
self.select_brawler(self.brawlers[index][0])
def select_brawler(self, name):
self.selected_brawler = name
self.canvas.itemconfigure(
self.status_item, text=f"Выбран: {name}", fill="#00ff88"
)
self.render_grid()
# ------------------------------------------------------------------
# Поля ввода и кнопка старта (стоят прямо на окне)
# ------------------------------------------------------------------
def setup_inputs(self):
entry_font = ctk.CTkFont(family=FONT_FAMILY, size=14)
button_font = ctk.CTkFont(family=FONT_FAMILY, size=15, weight="bold")
self.current_cups_entry = ctk.CTkEntry(
self, placeholder_text="Текущие кубки", width=150,
height=ENTRY_HEIGHT, font=entry_font,
)
self.current_cups_entry.place(relx=0.5, rely=1.0, x=-85, y=-115, anchor="s")
self.target_cups_entry = ctk.CTkEntry(
self, placeholder_text="Цель кубков", width=150,
height=ENTRY_HEIGHT, font=entry_font,
)
self.target_cups_entry.place(relx=0.5, rely=1.0, x=85, y=-115, anchor="s")
# Зелёная кнопка: поиск и выбор бойца по тексту
self.start_button = ctk.CTkButton(
self,
text="ПОДТВЕРДИТЬ ВЫБРАННЫЕ НАСТРОЙКИ",
font=ctk.CTkFont(family=FONT_FAMILY, size=16, weight="bold"),
fg_color="#00cc66",
hover_color="#00994d",
height=40,
command=self.click_start,
)
self.start_button.place(relx=0.5, rely=1.0, y=-60, anchor="s")
# Нижний ряд: BRAWLERS (сине-фиолетовая) и PLAY (жёлто-оранжевая)
self.brawlers_button = ctk.CTkButton(
self,
text="BRAWLERS",
font=button_font,
fg_color="#6a3de8",
hover_color="#4f2bb8",
width=170,
height=38,
command=self.click_brawlers,
)
self.brawlers_button.place(relx=0.5, rely=1.0, x=-95, y=-14, anchor="s")
self.play_button = ctk.CTkButton(
self,
text="PLAY",
font=button_font,
fg_color="#ffa500",
hover_color="#e68a00",
text_color="#1a1a1a",
width=170,
height=38,
command=self.click_play,
)
self.play_button.place(relx=0.5, rely=1.0, x=95, y=-14, anchor="s")
# ------------------------------------------------------------------
# Адаптивная раскладка
# ------------------------------------------------------------------
def on_configure(self, event):
if event.widget is not self:
return
if self._resize_job is not None:
self.after_cancel(self._resize_job)
self._resize_job = self.after(30, self.relayout)
def update_decor(self, w, h):
self.update_idletasks()
# Логотип по центру сверху
if self.logo_pil is not None:
max_h = LOGO_HEIGHT
max_w = max(w - 240, 100)
scale = min(
max_h / self.logo_pil.height, max_w / self.logo_pil.width
)
size = (
max(1, int(self.logo_pil.width * scale)),
max(1, int(self.logo_pil.height * scale)),
)
self.logo_photo = ImageTk.PhotoImage(
self.logo_pil.resize(size, Image.LANCZOS)
)
self.canvas.itemconfigure(self.logo_item, image=self.logo_photo)
self.canvas.coords(self.logo_item, w / 2, 15 + LOGO_HEIGHT / 2)
# Кубки по высоте строки ввода
entry1 = self.current_cups_entry
entry2 = self.target_cups_entry
entry_h = entry1.winfo_height()
if entry_h < 10:
return
center_y = entry1.winfo_y() + entry_h / 2
gap = 8
if self.trophy_pil is not None:
ratio = self.trophy_pil.width / self.trophy_pil.height
size = (max(1, int(entry_h * ratio)), entry_h)
self.trophy_photo = ImageTk.PhotoImage(
self.trophy_pil.resize(size, Image.LANCZOS)
)
self.canvas.itemconfigure(self.trophy_item, image=self.trophy_photo)
self.canvas.coords(self.trophy_item, entry1.winfo_x() - gap, center_y)
if self.trophy2_pil is not None:
ratio = self.trophy2_pil.width / self.trophy2_pil.height
size = (max(1, int(entry_h * ratio)), entry_h)
self.trophy2_photo = ImageTk.PhotoImage(
self.trophy2_pil.resize(size, Image.LANCZOS)
)
self.canvas.itemconfigure(self.trophy2_item, image=self.trophy2_photo)
self.canvas.coords(
self.trophy2_item,
entry2.winfo_x() + entry2.winfo_width() + gap,
center_y,
)
def relayout(self):
self._resize_job = None
w = self.winfo_width()
h = self.winfo_height()
if w < 50 or h < 50:
return
if self.bg_pil is not None:
self.bg_resized = self.bg_pil.resize((w, h), Image.LANCZOS)
self.bg_photo = ImageTk.PhotoImage(self.bg_resized)
self.canvas.itemconfigure(self.bg_item, image=self.bg_photo)
self.canvas.coords(self.title_item, w / 2, 15 + LOGO_HEIGHT / 2)
self.canvas.coords(self.subtitle_item, w / 2, 15 + LOGO_HEIGHT + 22)
self.canvas.coords(self.status_item, w / 2, 15 + LOGO_HEIGHT + 52)
if self.star_item is not None:
self.canvas.coords(self.star_item, w - 30, 20)
self.update_decor(w, h)
gx = 20
gy = 15 + LOGO_HEIGHT + 75
gw = max(w - 40, 50)
gh = max(h - gy - 160, 50)
self.grid_w = gw
self.grid_h = gh
self.grid_canvas.place(x=gx, y=gy, width=gw, height=gh)
if self.bg_resized is not None:
crop = self.bg_resized.crop((gx, gy, gx + gw, gy + gh))
self.grid_bg_photo = ImageTk.PhotoImage(crop)
self.grid_canvas.itemconfigure(self.grid_bg_item, image=self.grid_bg_photo)
self.render_grid()
# ------------------------------------------------------------------
# Старт и ADB
# ------------------------------------------------------------------
def set_status(self, text, color="#00ff88"):
self.canvas.itemconfigure(self.status_item, text=text, fill=color)
def set_error(self, text):
self.set_status(text, "#ff4500")
def log_status(self, text):
"""Вызывается из потоков бота: выводит сообщение в статус и в консоль."""
print(text)
self.after(0, self.set_status, text, "#ffffff")
# ------------------------------------------------------------------
# Бот: создание и запуск действий в фоновых потоках
# ------------------------------------------------------------------
def init_bot_async(self):
self.set_status("Загрузка распознавания текста (EasyOCR)...", "#ffcc00")
threading.Thread(target=self._init_bot, daemon=True).start()
def _init_bot(self):
try:
self.bot = BrawlBot(log=self.log_status)
except Exception as error:
print(f"Ошибка запуска бота: {error}")
self.after(0, self.set_error, f"Ошибка запуска бота: {error}")
return
self.after(0, self.show_ready_status)
def show_ready_status(self):
if self.selected_brawler:
self.set_status(f"Выбран: {self.selected_brawler}")
else:
self.set_status("Бот готов. Выберите бойца", "#00ff88")
def run_bot_task(self, method_name, *args):
if self.bot is None:
self.set_error("Бот ещё загружается, подождите...")
return
if self.busy:
self.set_error("Предыдущее действие ещё выполняется...")
return
self.busy = True
task = getattr(self.bot, method_name)
def worker():
try:
task(*args)
except Exception as error:
print(f"Ошибка: {error}")
short = str(error).splitlines()[0][:80]
self.after(0, self.set_error, f"Ошибка: {short}")
finally:
self.busy = False
threading.Thread(target=worker, daemon=True).start()
def click_brawlers(self):
self.run_bot_task("open_brawlers_menu")
def click_play(self):
self.run_bot_task("start_game_match")
def click_start(self):
if not self.selected_brawler:
self.set_error("Сначала выберите бойца!")
return
current_text = self.current_cups_entry.get().strip()
target_text = self.target_cups_entry.get().strip()
# Кубки необязательны, но если заполнены — должны быть числами
for text in (current_text, target_text):
if text and not text.isdigit():
self.set_error("Введите кубки числами!")
return
if self.bot is not None:
self.bot.current_cups = int(current_text) if current_text else None
self.bot.target_cups = int(target_text) if target_text else None
print(f"Боец: {self.selected_brawler}")
self.run_bot_task("select_brawler_and_play", self.selected_brawler)
if __name__ == "__main__":
ctk.set_appearance_mode("dark")
app = BrawlBotApp()
app.mainloop()