Загрузка данных
# -*- coding: utf-8 -*-
"""
Gold Ring Autoclicker (Windows)
F6 - toggle, F7 - screenshot, F8 - LMB test
Deps: pip install mss opencv-python numpy keyboard
"""
import ctypes as _ct
try:
_ct.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
try:
_ct.windll.user32.SetProcessDPIAware()
except Exception:
pass
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
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
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)
user32 = ctypes.WinDLL("user32", use_last_error=True)
if ctypes.sizeof(ctypes.c_void_p) == 8:
ULONG_PTR = ctypes.c_ulonglong
else:
ULONG_PTR = 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):
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, y):
user32.SetCursorPos(int(x), int(y))
def get_cursor_pos():
pt = wt.POINT()
user32.GetCursorPos(ctypes.byref(pt))
return pt.x, pt.y
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,
}
@dataclass
class Config:
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
new_target_min_dist: int = 50
new_target_radius_factor: float = 1.2
backend: str = "SendInput"
def detect_gold_ring(bgr, cfg):
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
class Worker(threading.Thread):
def __init__(self, cfg, zone, get_enabled, on_stats, on_click):
super().__init__(daemon=True)
self.cfg = cfg
self.zone = zone
self.get_enabled = get_enabled
self.on_stats = on_stats
self.on_click = on_click
self._stop = threading.Event()
self.last_click_center = None
self.last_click_radius = 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 _is_new(self, cx, cy, r):
if self.last_click_center is None:
return True
lx, ly = self.last_click_center
threshold = max(
self.cfg.new_target_min_dist,
self.cfg.new_target_radius_factor * max(r, self.last_click_radius),
)
return math.hypot(cx - lx, cy - ly) > threshold
def run(self):
try:
self._sct = mss.mss()
except Exception as e:
logger.exception("mss init failed: %s", e)
return
while not self._stop.is_set():
if not self.get_enabled() or not self.zone:
time.sleep(0.05)
continue
l, t, w, h = self.zone
if w <= 0 or h <= 0:
time.sleep(0.1)
continue
monitor = {"left": int(l), "top": int(t),
"width": int(w), "height": int(h)}
try:
shot = self._sct.grab(monitor)
img = np.asarray(shot)[:, :, :3].copy()
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:
fresh = []
for c in cands:
sx = l + c["cx"]
sy = t + c["cy"]
if self._is_new(sx, sy, c["r"]):
fresh.append((sx, sy, c))
if fresh:
expected = (self.cfg.min_radius + self.cfg.max_radius) / 2.0
fresh.sort(key=lambda v: abs(v[2]["r"] - expected))
sx, sy, c = fresh[0]
backend = BACKENDS.get(self.cfg.backend, click_sendinput)
try:
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: want (%d,%d) got (%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
time.sleep(0.03)
time.sleep(0.003)
logger.info("Worker stopped")
class ZoneSelector:
def __init__(self, parent, on_done):
self.on_done = on_done
self.top = tk.Toplevel(parent)
self.top.title("Zone select")
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="Drag mouse to select zone. ESC to cancel.",
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 = self.top.winfo_rootx()
oy = 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 = min(x0, x1)
topv = min(y0, y1)
w = abs(x1 - x0)
h = abs(y1 - y0)
self.top.destroy()
if w < 20 or h < 20:
self.on_done(None)
else:
self.on_done((left, topv, w, h))
def _cancel(self):
self.top.destroy()
self.on_done(None)
class App:
def __init__(self, root):
self.root = root
self.root.title("Gold Ring Autoclicker")
self.root.geometry("600x760")
self.root.resizable(False, False)
self.cfg = Config()
self.zone = None
self.enabled = False
self.worker = None
self.zone_selector = 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)
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="STOPPED", 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="clicks: 0 frames: 0",
font=("Consolas", 10))
self.stats_lbl.pack(side="right")
zf = tk.LabelFrame(self.root, text="Search zone")
zf.pack(fill="x", **pad)
self.zone_lbl = tk.Label(zf, text="not selected", font=("Consolas", 10))
self.zone_lbl.pack(side="left", padx=8, pady=6)
tk.Button(zf, text="Pick with mouse",
command=self._pick_zone).pack(side="right", padx=8, pady=6)
tf = tk.LabelFrame(self.root, text="LMB diagnostics")
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 TEST - press button below",
bg="#333", fg="#ddd", font=("Segoe UI", 10, "bold"))
self.test_lbl.pack(expand=True, fill="both")
self.test_area.bind("<Button-1>", self._on_test_click_received)
self.test_lbl.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 =.c ttk.Combobox(rowfg, 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="Run test",
command=self._run_lmb_test).pack(side="right")
self.test_result = tk.Label(tf, text="", font=("Consolas", 10),
wraplength=560, justify="left")
self.test_result.pack(anchor="w", padx=8, pady=(0, 6))
sf = tk.LabelFrame(self.root, text="Timings and thresholds")
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.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, "Delay after cursor move (ms)",
self.move_delay_var, 20, 300, 1)
self._slider(sf, "LMB down-to-up (ms)",
self.down_up_var, 5, 150, 1)
self._slider(sf, "Min distance to new ring (px)",
self.min_dist_var, 20, 300, 1)
self._slider(sf, "New-ring threshold (x radius)",
self.radius_factor_var, 0.5, 3.0, 0.1)
bf = tk.Frame(self.root)
bf.pack(fill="x", **pad)
tk.Button(bf, text="START / STOP (F6)",
font=("Segoe UI", 12, "bold"),
command=self._toggle_enabled
).pack(side="left", expand=True, fill="x", padx=(0, 4))
tk.Button(bf, text="Screenshot (F7)",
command=self._save_debug).pack(side="left", padx=4)
tk.Button(bf, text="Reset state",
command=self._reset_state).pack(side="left", padx=(4, 0))
lf = tk.LabelFrame(self.root, text="Log")
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("App started")
if not HAS_KEYBOARD:
self._gui_log("WARNING: keyboard module missing. "
"Install: pip install keyboard")
def _slider(self, parent, label, var, lo, hi, res):
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):
ts = time.strftime("%H:%M:%S")
try:
self.log_text.insert("end", "[" + 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("No search zone selected")
return
self.enabled = not self.enabled
if self.enabled:
self.status_lbl.config(text="RUNNING", bg="#2a7")
self._gui_log("F6 -> ON")
else:
self.status_lbl.config(text="STOPPED", bg="#a33")
self._gui_log("F6 -> OFF")
def _reset_state(self):
if self.worker:
self.worker.reset_state()
self._gui_log("last_click reset")
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),
cv2.MARKER_CROSS, 30, 2)
cv2.imwrite(DEBUG_IMG, img)
self._gui_log("Screenshot saved: " + DEBUG_IMG)
except Exception as e:
logger.exception("debug save failed: %s", e)
self._gui_log("Screenshot error: " + str(e))
def _run_lmb_test(self):
self._test_click_received = False
self._test_click_time = time.time()
self.test_result.config(text="Waiting for click...", 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="OK: Tkinter got Button-1. Windows accepts LMB. "
"If the game ignores it - filter is on app side.",
fg="#080")
self._gui_log("LMB test: OK")
else:
self.test_result.config(
text="FAIL: Button-1 not received. Try another backend, "
"check DPI, or run as administrator.",
fg="#a00")
self._gui_log("LMB test: FAILED")
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("Zone selection cancelled")
return
self.zone = zone
l, t, w, h = zone
self.zone_lbl.config(text="(" + str(l) + "," + str(t) + ") " +
str(w) + "x" + str(h))
if self.worker:
self.worker.set_zone(zone)
self.worker.reset_state()
self._save_zone(zone)
self._gui_log("Zone set")
def _save_zone(self, zone):
try:
with open(ZONE_FILE, "w", encoding="utf-8") as f:
f.write(str(zone[0]) + " " + str(zone[1]) + " " +
str(zone[2]) + " " + str(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="(" + str(l) + "," + str(t) + ") " +
str(w) + "x" + str(h))
if self.worker:
self.worker.set_zone(self.zone)
self._gui_log("Zone loaded from file")
except Exception as e:
logger.exception("zone load failed: %s", e)
def _on_worker_stats(self, clicks, frames):
txt = "clicks: " + str(clicks) + " frames: " + str(frames)
self.root.after(0, lambda: self.stats_lbl.config(text=txt))
def _on_worker_click(self, x, y, r):
msg = "CLICK (" + str(x) + "," + str(y) + ") r=" + str(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("Hotkeys F6/F7/F8 registered")
except Exception as e:
logger.exception("hotkey registration failed: %s", e)
self._gui_log("Hotkeys failed: " + str(e))
def _tick_ui(self):
try:
if self.enabled:
self.status_lbl.config(text="RUNNING", bg="#2a7")
else:
self.status_lbl.config(text="STOPPED", 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()
def main():
root = tk.Tk()
App(root)
root.mainloop()
if __name__ == "__main__":
main()