Init
This commit is contained in:
commit
7fa101820a
8 changed files with 493 additions and 0 deletions
11
.idea/.gitignore
generated
vendored
Normal file
11
.idea/.gitignore
generated
vendored
Normal 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
127
bot.py
Normal 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
48
capture.py
Normal 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
53
config.py
Normal 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
57
debug_snapshot.py
Normal 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
13
input_sender.py
Normal 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
6
requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
opencv-python
|
||||
numpy
|
||||
bettercam
|
||||
pydirectinput
|
||||
keyboard
|
||||
mss
|
||||
178
vision.py
Normal file
178
vision.py
Normal 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]
|
||||
Loading…
Add table
Reference in a new issue