Загрузка данных
# -*- coding: utf-8 -*-
"""
Gold Ring Autoclicker (Windows)
===============================
F6 - вкл/выкл автокликер
F7 - диагностический скриншот (gold_ring_debug.jpg)
F8 - тест LMB (клик по тестовой области GUI)
Зависимости: pip install mss opencv-python numpy keyboard
"""
# ------------------------------------------------------------------
# 0. DPI awareness — ДО импорта tkinter и до создания любых окон.
# ------------------------------------------------------------------
import ctypes as _ct
try:
_ct.windll.shcore.SetProcessDpiAwareness(2) # PER_MONITOR_AWARE_V2
except Exception:
try:
_ct.windll.user32.SetProcessDPIAware()
except Exception:
pass
# ------------------------------------------------------------------
# 1. Импорты
# ------------------------------------------------------------------
import os
import sys
import math
import time
import ctypes
import ctypes.wintypes as wt
import threading
import logging
from dataclasses import dataclass
from typing import Optional, Tuple, List
import tkinter as tk
from tkinter import ttk
import numpy as np
import cv2
import mss
try:
import keyboard as _kbd
HAS_KEYBOARD = True
except Exception:
HAS_KEYBOARD = False
_kbd = None
# ------------------------------------------------------------------
# 2. Пути и логирование
# ------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ZONE_FILE = os.path.join(BASE_DIR, "search_zone.txt")
LOG_FILE = os.path.join(BASE_DIR, "gold_ring_autoclicker.log")
DEBUG_IMG = os.path.join(BASE_DIR, "gold_ring_debug.jpg")
logger = logging.getLogger("gold_ring")
logger.setLevel(logging.INFO)
if not logger.handlers:
_fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
_fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(_fh)
_sh = logging.StreamHandler(sys.stdout)
_sh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(_sh)
# ------------------------------------------------------------------
# 3. Win32
# ------------------------------------------------------------------
user32 = ctypes.WinDLL("user32", use_last_error=True)
ULONG_PTR = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wt.LONG),
("dy", wt.LONG),
("mouseData", wt.DWORD),
("dwFlags", wt.DWORD),
("time", wt.DWORD),
("dwExtraInfo", ULONG_PTR),
]
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wt.WORD),
("wScan", wt.WORD),
("dwFlags", wt.DWORD),
("time", wt.DWORD),
("dwExtraInfo", ULONG_PTR),
]
class HARDWAREINPUT(ctypes.Structure):
_fields_ = [
("uMsg", wt.DWORD),
("wParamL", wt.WORD),
("wParamH", wt.WORD),
]
class _INPUT_UNION(ctypes.Union):
_fields_ = [
("mi", MOUSEINPUT),
("ki", KEYBDINPUT),
("hi", HARDWAREINPUT),
]
class INPUT(ctypes.Structure):
_anonymous_ = ("u",)
_fields_ = [
("type", wt.DWORD),
("u", _INPUT_UNION),
]
INPUT_MOUSE = 0
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
user32.SendInput.argtypes = (wt.UINT, ctypes.POINTER(INPUT), ctypes.c_int)
user32.SendInput.restype = wt.UINT
user32.SetCursorPos.argtypes = (ctypes.c_int, ctypes.c_int)
user32.SetCursorPos.restype = wt.BOOL
user32.GetCursorPos.argtypes = (ctypes.POINTER(wt.POINT),)
user32.GetCursorPos.restype = wt.BOOL
user32.mouse_event.argtypes = (wt.DWORD, wt.DWORD, wt.DWORD, wt.DWORD, ULONG_PTR)
user32.mouse_event.restype = None
def _send_input_mouse(flags: int) -> int:
inp = INPUT()
inp.type = INPUT_MOUSE
inp.mi.dx = 0
inp.mi.dy = 0
inp.mi.mouseData = 0
inp.mi.dwFlags = flags
inp.mi.time = 0
inp.mi.dwExtraInfo = 0
return user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT))
def set_cursor_pos(x: int, y: int) -> None:
user32.SetCursorPos(int(x), int(y))
def get_cursor_pos() -> Tuple[int, int]:
pt = wt.POINT()
user32.GetCursorPos(ctypes.byref(pt))
return pt.x, pt.y
# ------------------------------------------------------------------
# 4. Click-backends
# ------------------------------------------------------------------
def click_sendinput(x, y, move_delay_ms=60, down_up_ms=25):
set_cursor_pos(x, y)
time.sleep(move_delay_ms / 1000.0)
ax, ay = get_cursor_pos()
_send_input_mouse(MOUSEEVENTF_LEFTDOWN)
time.sleep(down_up_ms / 1000.0)
_send_input_mouse(MOUSEEVENTF_LEFTUP)
return ax, ay
def click_mouse_event(x, y, move_delay_ms=60, down_up_ms=25):
set_cursor_pos(x, y)
time.sleep(move_delay_ms / 1000.0)
ax, ay = get_cursor_pos()
user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
time.sleep(down_up_ms / 1000.0)
user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
return ax, ay
def click_pyautogui(x, y, move_delay_ms=60, down_up_ms=25):
import pyautogui
pyautogui.FAILSAFE = False
pyautogui.moveTo(int(x), int(y), duration=0)
time.sleep(move_delay_ms / 1000.0)
ax, ay = get_cursor_pos()
pyautogui.mouseDown(button="left")
time.sleep(down_up_ms / 1000.0)
pyautogui.mouseUp(button="left")
return ax, ay
BACKENDS = {
"SendInput": click_sendinput,
"mouse_event": click_mouse_event,
"PyAutoGUI": click_pyautogui,
}
# ------------------------------------------------------------------
# 5. Конфиг и детектор
# ------------------------------------------------------------------
@dataclass
class Config:
# HSV золото/жёлтый
h_min: int = 12
h_max: int = 30
s_min: int = 45
v_min: int = 145
# форма
min_area: float = 80.0
min_radius: int = 30
max_radius: int = 70
min_circularity: float = 0.60
min_aspect: float = 0.75
dark_center_max: int = 125
# тайминги клика
move_delay_ms: int = 60
down_up_ms: int = 25
# state machine
new_target_min_dist: int = 50
new_target_radius_factor: float = 1.2
# backend
backend: str = "SendInput"
def detect_gold_ring(bgr, cfg: Config):
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
lower = np.array([cfg.h_min, cfg.s_min, cfg.v_min], dtype=np.uint8)
upper = np.array([cfg.h_max, 255, 255], dtype=np.uint8)
mask = cv2.inRange(hsv, lower, upper)
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=1)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out = []
for c in contours:
area = cv2.contourArea(c)
if area < cfg.min_area:
continue
(cx, cy), radius = cv2.minEnclosingCircle(c)
if radius < cfg.min_radius or radius > cfg.max_radius:
continue
per = cv2.arcLength(c, True)
if per <= 1.0:
continue
circ = 4.0 * math.pi * area / (per * per)
if circ < cfg.min_circularity:
continue
x, y, w, h = cv2.boundingRect(c)
if h == 0:
continue
ar = w / float(h)
if ar < cfg.min_aspect or ar > 1.0 / cfg.min_aspect:
continue
# тёмный центр
ir = max(4, int(radius * 0.45))
x0 = max(0, int(cx) - ir); y0 = max(0, int(cy) - ir)
x1 = min(bgr.shape[1], int(cx) + ir); y1 = min(bgr.shape[0], int(cy) + ir)
if x1 <= x0 or y1 <= y0:
continue
patch = bgr[y0:y1, x0:x1]
gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY)
if gray.mean() > cfg.dark_center_max:
continue
out.append({"cx": int(cx), "cy": int(cy), "r": int(radius)})
return out, mask
# ------------------------------------------------------------------
# 6. Рабочий поток
# ------------------------------------------------------------------
class Worker(threading.Thread):
def __init__(self, cfg: Config, zone, get_enabled, on_stats, on_click):
super().__init__(daemon=True)
self.cfg = cfg
self.zone = zone # (l, t, w, h) в физических пикселях
self.get_enabled = get_enabled
self.on_stats = on_stats
self.on_click = on_click
self._stop = threading.Event()
self.last_click_center: Optional[Tuple[int, int]] = None
self.last_click_radius: int = 0
self.click_count = 0
self.frame_count = 0
self._sct = None
def stop(self):
self._stop.set()
def set_zone(self, zone):
self.zone = zone
def reset_state(self):
self.last_click_center = None
self.last_click_radius = 0
def _threshold(self, r: int) -> float:
return max(
self.cfg.new_target_min_dist,
self.cfg.new_target_radius_factor * max(r, self.last_click_radius),
)
def _is_new(self, cx: int, cy: int, r: int) -> bool:
if self.last_click_center is None:
return True
lx, ly = self.last_click_center
d = math.hypot(cx - lx, cy - ly)
return d > self._threshold(r)
def run(self):
end try:
self._sct = mss.mss()
except Exception as e:
logger.exception("mss init failed: %s", e)
return
, while not self._stop.is_set():
click if not self.get_enabled() or not self.zone:
time.sleep(0.05_s)
continue
l, t, w,end h = self.zone
if w <=input 0 or h <= 0:
)
time.sleep(0.1)
continue
monitor = {" tryleft": int(l), "top": int(t),
"width": int(w), "height": int(h)}
t0 = time.perf_counter()
try:
shot = self._sct.grab(monitor)
img = np.asarray(shot)[:, :, :3].copy() # BGRA -> BGR
except Exception as e:
logger.exception("grab failed: %s", e)
time.sleep(0.1)
continue
self.frame_count += 1
try:
cands, _ = detect_gold_ring(img, self.cfg)
except Exception as e:
logger.exception("detect failed: %s", e)
time.sleep(0.05)
continue
if cands:
new_list = []
for c in cands:
sx = l + c["cx"]
sy = t + c["cy"]
if self._is_new(sx, sy, c["r"]):
new_list.append((sx, sy, c))
if new_list:
expected = (self.cfg.min_radius + self.cfg.max_radius) / 2.0
new_list.sort(key=lambda v: abs(v[2]["r"] - expected))
sx, sy, c = new_list[0]
backend = BACKENDS.get(self.cfg.back:
ax, ay = backend(sx, sy,
self.cfg.move_delay_ms,
self.cfg.down_up_ms)
except Exception as e:
logger.exception("click failed: %s", e)
time.sleep(0.05)
continue
drift = math.hypot(ax - sx, ay - sy)
if drift > 5:
logger.warning("cursor drift %.1fpx: wanted (%d,%d), "
"actual (%d,%d)", drift, sx, sy, ax, ay)
self.last_click_center = (sx, sy)
self.last_click_radius = c["r"]
self.click_count += 1
logger.info("CLICK #%d at (%d,%d) r=%d backend=%s",
self.click_count, sx, sy, c["r"],
self.cfg.backend)
try:
self.on_click(sx, sy, c["r"])
except Exception:
pass
try:
self.on_stats(self.click_count, self.frame_count)
except Exception:
pass
# даём приложению 30 мс «прожевать» событие,
# затем продолжаем поиск без ожидания пустого кадра
time.sleep(0.03)
# не жрём CPU на полную
elapsed = time.perf_counter() - t0
if elapsed < 0.005:
time.sleep(0.005 - elapsed)
logger.info("Worker stopped")
# ------------------------------------------------------------------
# 7. Выбор области мышью
# ------------------------------------------------------------------
class ZoneSelector:
def __init__(self, parent, on_done):
self.on_done = on_done
self.top = tk.Toplevel(parent)
self.top.title("Выбор области поиска")
self.top.attributes("-fullscreen", True)
self.top.attributes("-topmost", True)
self.top.attributes("-alpha", 0.35)
self.top.configure(bg="black")
self.canvas = tk.Canvas(self.top, bg="black",
highlightthickness=0, cursor="crosshair")
self.canvas.pack(fill="both", expand=True)
sw = self.top.winfo_screenwidth()
self.canvas.create_text(
sw // 2, 60,
text="Обведите мышью область поиска. ESC - отмена.",
fill="yellow", font=("Segoe UI", 18, "bold"))
self.start = None
self.rect = None
self.canvas.bind("<ButtonPress-1>", self._press)
self.canvas.bind("<B1-Motion>", self._drag)
self.canvas.bind("<ButtonRelease-1>", self._release)
self.top.bind("<Escape>", lambda e: self._cancel())
self.top.focus_force()
def _press(self, e):
self.start = (e.x_root, e.y_root)
if self.rect:
self.canvas.delete(self.rect)
self.rect = self.canvas.create_rectangle(e.x, e.y, e.x, e.y,
outline="red", width=3)
def _drag(self, e):
if not self.start or not self.rect:
return
ox, oy = self.top.winfo_rootx(), self.top.winfo_rooty()
self.canvas.coords(self.rect,
self.start[0] - ox, self.start[1] - oy,
e.x, e.y)
def _release(self, e):
if not self.start:
return
x0, y0 = self.start
x1, y1 = e.x_root, e.y_root
left, top_ = min(x0, x1), min(y0, y1)
w, h = abs(x1 - x0), abs(y1 - y0)
self.top.destroy()
if w < 20 or h < 20:
self.on_done(None)
else:
self.on_done((left, top_, w, h))
def _cancel(self):
self.top.destroy()
self.on_done(None)
# ------------------------------------------------------------------
# 8. GUI
# ------------------------------------------------------------------
class App:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("Gold Ring Autoclicker")
self.root.geometry("580x720")
self.root.resizable(False, False)
self.cfg = Config()
self.zone: Optional[Tuple[int, int, int, int]] = None
self.enabled = False
self.worker: Optional[Worker] = None
self.zone_selector: Optional[ZoneSelector] = None
self._test_click_received = False
self._test_click_time = None
self._build_ui()
self._load_zone()
self.worker = Worker(
self.cfg, self.zone,
get_enabled=lambda: self.enabled,
on_stats=self._on_worker_stats,
on_click=self._on_worker_click,
)
self.worker.start()
self._install_hotkeys()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
self.root.after(200, self._tick_ui)
# ---------- UI ----------
def _build_ui(self):
pad = {"padx": 8, "pady": 4}
# статус
top = tk.Frame(self.root)
top.pack(fill="x", **pad)
self.status_lbl = tk.Label(top, text="ОСТАНОВЛЕНО", bg="#a33",
fg="white", font=("Segoe UI", 14, "bold"),
padx=12, pady=8)
self.status_lbl.pack(side="left")
self.stats_lbl = tk.Label(top, text="кликов: 0 кадров: 0",
font=("Consolas", 10))
self.stats_lbl.pack(side="right")
# зона
zf = tk.LabelFrame(self.root, text="Область поиска")
zf.pack(fill="x", **pad)
self.zone_lbl = tk.Label(zf, text="не выбрана", font=("Consolas", 10))
self.zone_lbl.pack(side="left", padx=8, pady=6)
tk.Button(zf, text="Выбрать мышью",
command=self._pick_zone).pack(side="right", padx=8, pady=6)
# диагностика LMB
tf = tk.LabelFrame(self.root, text="Диагностика LMB")
tf.pack(fill="x", **pad)
self.test_area = tk.Frame(tf, bg="#333", height=70, cursor="hand2")
self.test_area.pack(fill="x", padx=8, pady=6)
self.test_area.pack_propagate(False)
self.test_lbl = tk.Label(
self.test_area,
text="ТЕСТ LMB — нажми кнопку ниже\n(курсор переместится сюда и кликнет)",
bg="#333", fg="#ddd", font=("Segoe UI", 10, "bold"))
self.test_lbl.pack(expand=True, fill="both")
for w in (self.test_area, self.test_lbl):
w.bind("<Button-1>", self._on_test_click_received)
row = tk.Frame(tf)
row.pack(fill="x", padx=8, pady=(0, 6))
self.backend_var = tk.StringVar(value=self.cfg.backend)
tk.Label(row, text="Backend:").pack(side="left")
cb = ttk.Combobox(row, textvariable=self.backend_var,
values=list(BACKENDS.keys()),
state="readonly", width=14)
cb.pack(side="left", padx=6)
cb.bind("<<ComboboxSelected>>",
lambda e: setattr(self.cfg, "backend", self.backend_var.get()))
tk.Button(row, text="Запустить тест",
command=self._run_lmb_test).pack(side="right")
self.test_result = tk.Label(tf, text="", font=("Consolas", 10),
wraplength=540, justify="left")
self.test_result.pack(anchor="w", padx=8, pady=(0, 6))
# тайминги и пороги
sf = tk.LabelFrame(self.root, text="Тайминги и пороги")
sf.pack(fill="x", **pad)
self.move_delay_var = tk.IntVar(value=self.cfg.move_delay_ms)
self.down_up_var = tk.IntVar(value=self.cfg.down_up_ms)
self.min_dist_var = tk.IntVar(value=self.cfg.new_target_min_dist)
self.radius_factor_var = tk.DoubleVar(value=self.cfg.new_target_radius_factor)
self._slider(sf, "Задержка после перемещения курсора (ms)",
self.move_delay_var, 20, 300, 1, 1)
self._slider(sf, "Длительность LMB down→up (ms)",
self.down_up_var, 5, 150, 1, 1)
self._slider(sf, "Мин. дистанция до нового кольца (px)",
self.min_dist_var, 20, 300, 1, 1)
self._slider(sf, "Порог нового кольца (× радиус)",
self.radius_factor_var, 0.5, 3.0, 0.1, 0.1)
# кнопки
bf = tk.Frame(self.root)
bf.pack(fill="x", **pad)
tk.Button(bf, text="СТАРТ / СТОП (F6)",
font=("Segoe UI", 12, "bold"),
command=self._toggle_enabled
).pack(side="left", expand=True, fill="x", padx=(0, 4))
tk.Button(bf, text="Скриншот (F7)",
command=self._save_debug).pack(side="left", padx=4)
tk.Button(bf, text="Сброс состояния",
command=self._reset_state).pack(side="left", padx=(4, 0))
# лог
lf = tk.LabelFrame(self.root, text="Лог")
lf.pack(fill="both", expand=True, **pad)
self.log_text = tk.Text(lf, height=12, font=("Consolas", 9), wrap="none")
self.log_text.pack(fill="both", expand=True, padx=4, pady=4)
self._gui_log("Приложение запущено")
if not HAS_KEYBOARD:
self._gui_log("ВНИМАНИЕ: модуль keyboard недоступен — "
"глобальные F6/F7/F8 работать не будут. "
"Установите: pip install keyboard")
def _slider(self, parent, label, var, lo, hi, res, default):
f = tk.Frame(parent)
f.pack(fill="x", padx=8, pady=2)
tk.Label(f, text=label, width=42, anchor="w").pack(side="left")
tk.Scale(f, from_=lo, to=hi, orient="horizontal",
variable=var, resolution=res, showvalue=True,
length=210, command=lambda v: self._update_timing()
).pack(side="right")
# ---------- утилиты ----------
def _gui_log(self, msg: str):
ts = time.strftime("%H:%M:%S")
try:
self.log_text.insert("end", f"[{ts}] {msg}\n")
self.log_text.see("end")
except Exception:
pass
def _update_timing(self):
self.cfg.move_delay_ms = int(self.move_delay_var.get())
self.cfg.down_up_ms = int(self.down_up_var.get())
self.cfg.new_target_min_dist = int(self.min_dist_var.get())
self.cfg.new_target_radius_factor = float(self.radius_factor_var.get())
# ---------- действия ----------
def _toggle_enabled(self):
if not self.zone:
self._gui_log("Не выбрана область поиска")
return
self.enabled = not self.enabled
if self.enabled:
self.status_lbl.config(text="РАБОТАЕТ", bg="#2a7")
self._gui_log("F6 → ВКЛ")
else:
self.status_lbl.config(text="ОСТАНОВЛЕНО", bg="#a33")
self._gui_log("F6 → ВЫКЛ")
def _reset_state(self):
if self.worker:
self.worker.reset_state()
self._gui_log("Сброшено last_click")
def _save_debug(self):
try:
with mss.mss() as sct:
if self.zone:
l, t, w, h = self.zone
mon = {"left": l, "top": t, "width": w, "height": h}
else:
mon = sct.monitors[1]
shot = sct.grab(mon)
img = np.asarray(shot)[:, :, :3].copy()
if self.zone and self.worker and self.worker.last_click_center:
lx, ly = selfтора.worker.last_click_center
l0, t0, _,." _ = self.zone
cv2.drawMarker(img, (lx -),
l0, ly - t0), ( 0, 0, 255),
cv f2.MARKER_CROSS, 30, 2)
g cv2.imwrite(DEBUG_IMG, img)
="# self._gui_log(f"Скриншот сохaранён: {DEBUG_IMG}")
except00")
Exception as e :
logger.exception("debug save failed: %s self", e)
self._gui_log(f"Ошибка скриншота: {e}")
def _run_lmb_test(self):
self._test_click_received = False
self._test_click_time = time.time()
self.test_result.config(text="Ожидание клика...", fg="#666")
self.root.lift()
self.root.attributes("-topmost", True)
self.root.after(150, lambda: self.root.attributes("-topmost", False))
self.root.update_idletasks()
x = self.test_area.winfo_rootx() + self.test_area.winfo_width() // 2
y = self.test_area.winfo_rooty() + self.test_area.winfo_height() // 2
def do():
time.sleep(0.20)
try:
backend = BACKENDS.get(self.cfg.backend, click_sendinput)
ax, ay = backend(x, y, self.cfg.move_delay_ms, self.cfg.down_up_ms)
logger.info("LMB test: target=(%d,%d) actual=(%d,%d) drift=%.1f",
x, y, ax, ay, math.hypot(ax - x, ay - y))
except Exception as e:
logger.exception("LMB test failed: %s", e)
threading.Thread(target=do, daemon=True).start()
self.root.after(900, self._check_test_result)
def _on_test_click_received(self, _event):
if self._test_click_time is None:
return
self._test_click_received = True
dt = (time.time() - self._test_click_time) * 1000.0
logger.info("LMB test: Tkinter received <Button-1> after %.0f ms", dt)
def _check_test_result(self):
if self._test_click_received:
self.test_result.config(
text=("✓ Tkinter получил Button-1 — Windows/backend OK. "
"Если игра не реагирует — фильтр на её стороне "
"(DirectInput/античит)."),
fg="#080")
self._gui_log("Тест LMB: OK (Button-1 получен)")
else:
self.test_result.config(
text=("✗ Button-1 не получен. Проблема в backend/DPI/правах. "
"Попробуйте другой backend или запуск от администра._gui_log("Тест LMB: НЕ получен Button-1")
def _pick_zone(self):
if self.zone_selector is not None:
return
self.zone_selector = ZoneSelector(self.root, self._zone_selected)
def _zone_selected(self, zone):
self.zone_selector = None
if zone is None:
self._gui_log("Выбор области отменён")
return
self.zone = zone
l, t, w, h = zone
self.zone_lbl.config(text=f"({l},{t}) {w}×{h}")
if self.worker:
self.worker.set_zone(zone)
self.worker.reset_state()
self._save_zone(zone)
self._gui_log(f"Область: ({l},{t}) {w}×{h}")
def _save_zone(self, zone):
try:
with open(ZONE_FILE, "w", encoding="utf-8") as f:
f.write(f"{zone[0]} {zone[1]} {zone[2]} {zone[3]}\n")
except Exception as e:
logger.exception("zone save failed: %s", e)
def _load_zone(self):
if not os.path.exists(ZONE_FILE):
return
try:
with open(ZONE_FILE, "r", encoding="utf-8") as f:
parts = f.read().split()
if len(parts) >= 4:
l, t, w, h = map(int, parts[:4])
if w > 0 and h > 0:
self.zone = (l, t, w, h)
self.zone_lbl.config(text=f"({l},{t}) {w}×{h}")
if self.worker:
self.worker.set_zone(self.zone)
self._gui_log(f"Загружена область: ({l},{t}) {w}×{h}")
except Exception as e:
logger.exception("zone load failed: %s", e)
# ---------- обратные вызовы из воркера ----------
def _on_worker_stats(self, clicks, frames):
self.root.after(0, lambda: self.stats_lbl.config(
text=f"кликов: {clicks} кадров: {frames}"))
def _on_worker_click(self, x, y, r):
msg = f"CLICK ({x},{y}) r={r}"
self.root.after(0, lambda: self._gui_log(msg))
# ---------- глобальные хоткеи ----------
def _install_hotkeys(self):
if not HAS_KEYBOARD:
return
try:
_kbd.add_hotkey("f6", lambda: self.root.after(0, self._toggle_enabled))
_kbd.add_hotkey("f7", lambda: self.root.after(0, self._save_debug))
_kbd.add_hotkey("f8", lambda: self.root.after(0, self._run_lmb_test))
logger.info("Global hotkeys registered (F6/F7/F8)")
except Exception as e:
logger.exception("hotkey registration failed: %s", e)
self._gui_log(f"Не удалось установить глобальные хоткеи: {e}")
# ---------- UI-тик ----------
def _tick_ui(self):
try:
if self.enabled:
self.status_lbl.config(text="РАБОТАЕТ", bg="#2a7")
else:
self.status_lbl.config(text="ОСТАНОВЛЕНО", bg="#a33")
except Exception:
pass
self.root.after(300, self._tick_ui)
# ---------- закрытие ----------
def _on_close(self):
self.enabled = False
try:
if self.worker:
self.worker.stop()
except Exception:
pass
if HAS_KEYBOARD:
try:
_kbd.unhook_all_hotkeys()
except Exception:
pass
self.root.destroy()
# ------------------------------------------------------------------
# 9. main
# ------------------------------------------------------------------
def main():
root = tk.Tk()
App(root)
root.mainloop()
if __name__ == "__main__":
main()