This commit is contained in:
MasterAkulon 2026-06-13 12:19:38 +03:00
commit 7fa101820a
8 changed files with 493 additions and 0 deletions

11
.idea/.gitignore generated vendored Normal file
View file

@ -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/

127
bot.py Normal file
View file

@ -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()

48
capture.py Normal file
View file

@ -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]

53
config.py Normal file
View file

@ -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

57
debug_snapshot.py Normal file
View file

@ -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()

13
input_sender.py Normal file
View file

@ -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)

6
requirements.txt Normal file
View file

@ -0,0 +1,6 @@
opencv-python
numpy
bettercam
pydirectinput
keyboard
mss

178
vision.py Normal file
View file

@ -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]