commit 7fa101820acf11cee30980f40c630f54218298ff Author: MasterAkulon Date: Sat Jun 13 12:19:38 2026 +0300 Init diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..32a3e97 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,11 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ +/.idea/ \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..da7f9a6 --- /dev/null +++ b/bot.py @@ -0,0 +1,127 @@ +import time + +import keyboard + +import config +import vision +from capture import ScreenCapture +from input_sender import press_action_key + + +class Bot: + def __init__(self): + self.capture = ScreenCapture() + self.enabled = False + self.bar_bbox = None + self.was_in_zone = False + self.miss_count = 0 + self.last_locate = 0.0 + self.last_track_time = 0.0 + self.prev_cursor_x = None + + def toggle(self): + self.enabled = not self.enabled + print(f"[Bot] {'ВКЛЮЧЕН' if self.enabled else 'выключен'}") + if not self.enabled: + self.bar_bbox = None + self.was_in_zone = False + self.prev_cursor_x = None + + def run(self): + keyboard.add_hotkey(config.TOGGLE_HOTKEY, self.toggle) + print(f"Захват экрана: {self.capture.backend}") + print(f"{config.TOGGLE_HOTKEY.upper()} - включить/выключить бота. Ctrl+C - выход.") + + frame_interval = 1.0 / config.TRACK_FPS + try: + while True: + t0 = time.time() + if not self.enabled: + time.sleep(0.05) + continue + + if self.bar_bbox is None: + self._try_locate() + else: + self._track() + + elapsed = time.time() - t0 + if elapsed < frame_interval: + time.sleep(frame_interval - elapsed) + except KeyboardInterrupt: + pass + + def _try_locate(self): + now = time.time() + if now - self.last_locate < config.LOCATE_RETRY_INTERVAL: + time.sleep(0.01) + return + self.last_locate = now + + frame = self.capture.grab() + if frame is None: + return + bbox = vision.locate_bar(frame) + if bbox: + self.bar_bbox = bbox + self.miss_count = 0 + print(f"[Bot] Шкала найдена: {bbox}") + + def _track(self): + now = time.time() + loop_dt = now - self.last_track_time if self.last_track_time else 0.0 + self.last_track_time = now + + bx, by, bw, bh = self.bar_bbox + screen_w, screen_h = self.capture.screen_size() + margin_x = bw // 10 + region_height = bh + int(bh * config.CURSOR_SEARCH_HEIGHT_RATIO) + region = ( + max(0, bx - margin_x), + by, + min(screen_w, bx + bw + margin_x), + min(screen_h, by + region_height), + ) + + frame = self.capture.grab(region=region) + if frame is None: + return + + local_bbox = (bx - region[0], 0, bw, bh) + + zone = vision.find_target_zone(frame, local_bbox) + cursor_x = vision.locate_cursor(frame, local_bbox) + + if zone is None or cursor_x is None: + self.miss_count += 1 + if self.miss_count > config.MAX_TRACK_MISSES: + print("[Bot] Шкала пропала с экрана, ищу заново...") + self.bar_bbox = None + self.was_in_zone = False + self.prev_cursor_x = None + return + self.miss_count = 0 + + # Предсказываем, где курсор окажется через CURSOR_LEAD_TIME секунд, + # чтобы скомпенсировать задержку захвата/обработки/нажатия - иначе + # к моменту срабатывания клавиши курсор уже покидает узкую зону. + velocity = 0.0 + if self.prev_cursor_x is not None and loop_dt > 0: + velocity = (cursor_x - self.prev_cursor_x) / loop_dt + self.prev_cursor_x = cursor_x + predicted_x = cursor_x + velocity * config.CURSOR_LEAD_TIME + + x_min, x_max = zone + in_zone = x_min <= predicted_x <= x_max + + if in_zone and not self.was_in_zone: + press_action_key() + print( + f"[Bot] E нажата | cursor_x={cursor_x:.1f} predicted_x={predicted_x:.1f} " + f"zone=({x_min},{x_max}) | задержка цикла={loop_dt * 1000:.1f} мс" + ) + self.was_in_zone = in_zone + + +if __name__ == "__main__": + Bot().run() \ No newline at end of file diff --git a/capture.py b/capture.py new file mode 100644 index 0000000..f0fcce0 --- /dev/null +++ b/capture.py @@ -0,0 +1,48 @@ +import numpy as np +import mss + +try: + import bettercam as _bc +except ImportError: + _bc = None + + +class ScreenCapture: + """Захват экрана. Использует bettercam (DXGI Desktop Duplication) - + он умеет захватывать эксклюзивный полноэкранный режим. Если bettercam + недоступен или не смог создать камеру, откатывается на mss.""" + + def __init__(self, output_idx=0): + self._cam = None + self._sct = None + if _bc is not None: + try: + self._cam = _bc.create(output_idx=output_idx, output_color="BGR") + except Exception: + self._cam = None + if self._cam is None: + self._sct = mss.mss() + + @property + def backend(self): + return "bettercam" if self._cam else "mss" + + def screen_size(self): + if self._cam: + return self._cam.width, self._cam.height + mon = self._sct.monitors[1] + return mon["width"], mon["height"] + + def grab(self, region=None): + """region = (left, top, right, bottom) в абсолютных координатах экрана. + Возвращает кадр BGR (numpy array) или None, если новый кадр недоступен.""" + if self._cam: + return self._cam.grab(region=region) + + if region is None: + mon = self._sct.monitors[1] + else: + left, top, right, bottom = region + mon = {"left": left, "top": top, "width": right - left, "height": bottom - top} + shot = np.asarray(self._sct.grab(mon)) + return shot[:, :, :3] \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..54de0ac --- /dev/null +++ b/config.py @@ -0,0 +1,53 @@ +# Горячая клавиша вкл/выкл бота +TOGGLE_HOTKEY = "f6" + +# Клавиша, которую бот нажимает при попадании курсора в узкую (целевую) зону +ACTION_KEY = "e" +KEY_PRESS_DELAY = 0.03 # длительность удержания клавиши, сек + +# HSV-диапазон (OpenCV: H 0-179) для "синего" - им окрашены и сама шкала, и курсор. +# Панель полупрозрачная, поэтому яркость/насыщенность шкалы заметно меняются +# в зависимости от того, что видно позади неё (тёмная сцена против яркой) - +# пороги намеренно ослаблены, чтобы захватывать оба случая. +BLUE_HUE_MIN = 85 +BLUE_HUE_MAX = 135 +BLUE_SAT_MIN = 100 +BLUE_VAL_MIN = 100 + +# Критерии формы шкалы (вытянутый синий прямоугольник) +BAR_MIN_WIDTH_RATIO = 0.06 # минимальная ширина шкалы относительно ширины экрана +BAR_MAX_HEIGHT_RATIO = 0.08 # максимальная высота шкалы относительно высоты экрана +BAR_MIN_ASPECT_RATIO = 4.0 # ширина / высота +BAR_COLUMN_MERGE_GAP_RATIO = 0.05 # макс. разрыв между фрагментами шкалы (из-за зон), относительно ширины экрана +BAR_MIN_FILL_RATIO = 0.6 # доля синих пикселей внутри прямоугольника шкалы (она почти сплошная) + +# Критерии курсора (отдельное синее пятно под шкалой) +CURSOR_MAX_WIDTH_RATIO = 0.5 # относительно ширины шкалы +CURSOR_SEARCH_HEIGHT_RATIO = 2.0 # во сколько раз зона поиска курсора выше шкалы + +# Зоны (узкая/широкая) - яркие цветные пятна внутри шкалы, не считая синего +# фона самой шкалы. В разных мини-играх насыщенность зон отличается заметно +# (вплоть до ~45 у блёклой кремовой широкой зоны), поэтому порог занижен - +# от ложных срабатываний защищает исключение синего оттенка (_blue_hue_mask). +ZONE_SAT_MIN = 40 +ZONE_VAL_MIN = 100 +ZONE_MIN_AREA = 30 # минимальный размер пятна-зоны, в пикселях +ZONE_MIN_HEIGHT_RATIO = 0.5 # пятно должно покрывать минимум половину высоты шкалы + +# Интервал повторных попыток найти шкалу, когда она не видна на экране (сек) +LOCATE_RETRY_INTERVAL = 1 + +# После скольких подряд неудачных кадров считать, что шкала исчезла, и искать заново +MAX_TRACK_MISSES = 30 + +# Целевая частота опроса в режиме отслеживания курсора (кадров/сек) +TRACK_FPS = 60 + +# На сколько секунд вперёд предсказывать положение курсора при проверке +# попадания в зону - компенсирует суммарную задержку (захват кадра + обработка +# + время до того, как нажатие клавиши подействует в игре). Курсор может +# проскочить узкую зону за время этой задержки, поэтому решение о нажатии +# принимается по предсказанной позиции = текущая + скорость * это время. +# Увеличивайте, если нажатие происходит позже, чем нужно; уменьшайте/обнулите, +# если нажатие происходит раньше входа в зону. +CURSOR_LEAD_TIME = 0.10 \ No newline at end of file diff --git a/debug_snapshot.py b/debug_snapshot.py new file mode 100644 index 0000000..e1982a5 --- /dev/null +++ b/debug_snapshot.py @@ -0,0 +1,57 @@ +"""Однократный снимок экрана с отрисовкой найденной шкалы, целевой зоны и курсора. +Запускать, когда мини-игра видна на экране - результат сохраняется в debug_snapshot.png.""" +from time import sleep + +import cv2 + +import vision +from capture import ScreenCapture + + +def main(): + cap = ScreenCapture() + print(f"Захват экрана: {cap.backend}") + + frame = cap.grab() + if frame is None: + print("Не удалось получить кадр.") + if cap.backend == "mss": + print("mss обычно не видит эксклюзивный полноэкранный режим.") + print("Установите bettercam (pip install bettercam) или переключите игру в borderless.") + return + + out = frame.copy() + bar = vision.locate_bar(frame) + if bar is None: + print("Шкала не найдена. Откройте мини-игру на экране и запустите снова,") + print("либо подправьте пороги BLUE_* в config.py.") + cv2.imwrite("debug_full.png", out) + print("Сохранён полный кадр: debug_full.png") + return + + bx, by, bw, bh = bar + print(f"Шкала: x={bx}, y={by}, w={bw}, h={bh}") + cv2.rectangle(out, (bx, by), (bx + bw, by + bh), (255, 0, 0), 2) + + zone = vision.find_target_zone(frame, bar) + if zone: + zx0, zx1 = zone + print(f"Узкая (целевая) зона: x от {zx0} до {zx1}") + cv2.rectangle(out, (zx0, by), (zx1, by + bh), (0, 0, 255), 2) + else: + print("Целевая зона не найдена.") + + cursor_x = vision.locate_cursor(frame, bar) + if cursor_x is not None: + print(f"Курсор: x = {cursor_x:.1f}") + cv2.line(out, (int(cursor_x), by - 5), (int(cursor_x), by + bh + 5), (255, 0, 255), 2) + else: + print("Курсор не найден.") + + cv2.imwrite("debug_snapshot.png", out) + print("Сохранён debug_snapshot.png") + + +if __name__ == "__main__": + sleep(5) + main() \ No newline at end of file diff --git a/input_sender.py b/input_sender.py new file mode 100644 index 0000000..22c80e1 --- /dev/null +++ b/input_sender.py @@ -0,0 +1,13 @@ +import time + +import pydirectinput + +import config + +pydirectinput.PAUSE = 0 + + +def press_action_key(): + pydirectinput.keyDown(config.ACTION_KEY) + time.sleep(config.KEY_PRESS_DELAY) + pydirectinput.keyUp(config.ACTION_KEY) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..86fac20 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +opencv-python +numpy +bettercam +pydirectinput +keyboard +mss \ No newline at end of file diff --git a/vision.py b/vision.py new file mode 100644 index 0000000..0992463 --- /dev/null +++ b/vision.py @@ -0,0 +1,178 @@ +import cv2 +import numpy as np + +import config + + +def blue_mask(bgr): + """Маска пикселей синего семейства оттенков - этим цветом окрашены и фон + шкалы, и маркер курсора (они могут быть визуально одного цвета).""" + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + lower = np.array([config.BLUE_HUE_MIN, config.BLUE_SAT_MIN, config.BLUE_VAL_MIN]) + upper = np.array([config.BLUE_HUE_MAX, 255, 255]) + return cv2.inRange(hsv, lower, upper) + + +def _blue_hue_mask(bgr): + """Маска по одному оттенку, без учёта насыщенности/яркости - зоны не бывают + синеватого оттенка даже в приглушённом виде, поэтому она шире blue_mask + и не даёт тусклым синим пикселям шкалы попасть в зоны.""" + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + return cv2.inRange(hsv, (config.BLUE_HUE_MIN, 0, 0), (config.BLUE_HUE_MAX, 255, 255)) + + +def _contiguous_runs(bool_row): + """Возвращает список (start, end) индексов подряд идущих True.""" + runs = [] + in_run = False + start = 0 + for i, v in enumerate(bool_row): + if v and not in_run: + start = i + in_run = True + elif not v and in_run: + runs.append((start, i - 1)) + in_run = False + if in_run: + runs.append((start, len(bool_row) - 1)) + return runs + + +def _merge_runs(runs, max_gap): + """Объединяет соседние (start, end) интервалы, если разрыв между ними <= max_gap.""" + if not runs: + return [] + runs = sorted(runs) + merged = [list(runs[0])] + for s, e in runs[1:]: + if s - merged[-1][1] <= max_gap: + merged[-1][1] = max(merged[-1][1], e) + else: + merged.append([s, e]) + return [(s, e) for s, e in merged] + + +def locate_bar(frame_bgr): + """Ищет на кадре вытянутую синюю шкалу - широкую горизонтальную полосу + сплошного синего цвета (плотность пикселей в строке, а не просто диапазон, + чтобы не путать со разреженным синеватым фоном сцены/панели). + Возвращает (x, y, w, h) в координатах кадра или None.""" + h_frame, w_frame = frame_bgr.shape[:2] + mask = blue_mask(frame_bgr) > 0 + + row_count = mask.sum(axis=1) + width_threshold = w_frame * config.BAR_MIN_WIDTH_RATIO + max_height = h_frame * config.BAR_MAX_HEIGHT_RATIO + max_gap = w_frame * config.BAR_COLUMN_MERGE_GAP_RATIO + + wide_rows = row_count > width_threshold + + candidates = [] + for y0, y1 in _contiguous_runs(wide_rows): + bh = y1 - y0 + 1 + if bh > max_height: + continue + + cols_any = mask[y0:y1 + 1].any(axis=0) + col_runs = _contiguous_runs(cols_any) + merged = _merge_runs(col_runs, max_gap) + if not merged: + continue + cs, ce = max(merged, key=lambda r: r[1] - r[0]) + bx, bw = cs, ce - cs + 1 + + if bw / bh < config.BAR_MIN_ASPECT_RATIO: + continue + if bw < width_threshold: + continue + + # Шкала - почти сплошной синий прямоугольник, в отличие от похожих + # по цвету фоновых объектов (земля, небо, листва), где синие пиксели + # покрывают лишь часть площади. + region = mask[y0:y1 + 1, bx:bx + bw] + fill_ratio = region.sum() / (bw * bh) + if fill_ratio < config.BAR_MIN_FILL_RATIO: + continue + + candidates.append((bx, int(y0), bw, bh)) + + if not candidates: + return None + + # Дополнительно: внутри шкалы должны лежать два цветных пятна-зоны. + # Если ни у одного кандидата таких зон нет, значит шкала сейчас не видна. + with_zones = [c for c in candidates if find_target_zone(frame_bgr, c) is not None] + if not with_zones: + return None + return max(with_zones, key=lambda c: c[2]) + + +def locate_cursor(frame_bgr, bar_bbox): + """Курсор - "хвостик" того же синего цвета, выступающий из-под шкалы снизу. + Под шкалой могут быть и другие синеватые элементы интерфейса (иконки, + текст), поэтому ищем именно пятно, примыкающее к нижней границе шкалы, + и берём центр наибольшего из таких пятен. + Возвращает x-координату центра курсора в координатах кадра или None.""" + bx, by, bw, bh = bar_bbox + mask = blue_mask(frame_bgr) + + search_h = int(bh * config.CURSOR_SEARCH_HEIGHT_RATIO) + y0 = by + bh + y1 = min(frame_bgr.shape[0], y0 + search_h) + x0, x1 = bx, min(frame_bgr.shape[1], bx + bw) + if y1 <= y0 or x1 <= x0: + return None + + region = mask[y0:y1, x0:x1] + touch_tolerance = max(2, int(bh * 0.1)) + contours, _ = cv2.findContours(region, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + best = None + best_area = 0 + for c in contours: + area = cv2.contourArea(c) + if area < config.ZONE_MIN_AREA: + continue + _, y, _, _ = cv2.boundingRect(c) + if y > touch_tolerance: + continue + if area > best_area: + best_area = area + best = c + if best is None: + return None + x, _, w, _ = cv2.boundingRect(best) + return x0 + x + w / 2.0 + + +def find_target_zone(frame_bgr, bar_bbox): + """Находит самую узкую цветную зону внутри шкалы (целевую) - это яркое + насыщенное пятно, отличное от синего фона шкалы. + Возвращает (x_min, x_max) в координатах кадра или None.""" + bx, by, bw, bh = bar_bbox + bar = frame_bgr[by:by + bh, bx:bx + bw] + if bar.size == 0: + return None + + hsv = cv2.cvtColor(bar, cv2.COLOR_BGR2HSV) + not_blue = cv2.bitwise_not(_blue_hue_mask(bar)) + vivid = cv2.inRange(hsv, (0, config.ZONE_SAT_MIN, config.ZONE_VAL_MIN), (179, 255, 255)) + mask_zones = cv2.bitwise_and(not_blue, vivid) + + contours, _ = cv2.findContours(mask_zones, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + min_zone_height = bh * config.ZONE_MIN_HEIGHT_RATIO + zones = [] + for c in contours: + if cv2.contourArea(c) < config.ZONE_MIN_AREA: + continue + x, y, w, h = cv2.boundingRect(c) + if h < min_zone_height: + continue + zones.append((x, x + w)) + + # Должно быть минимум две зоны (узкая+широкая). Если найдена только одна + # или ни одной - вероятно, шум или сбой детекции, лучше пропустить кадр. + if len(zones) < 2: + return None + + narrow = min(zones, key=lambda z: z[1] - z[0]) + return bx + narrow[0], bx + narrow[1] \ No newline at end of file