파판7 리메이크 미니게임이 너무 어려워서 자동 조준 툴을 만드는 중인데요.
마우스로 원하는 지점의 색상을 클릭하면 그곳으로 WASD키를 누르면서 자동으로 조준 보정을 하는 툴을 만들고 있습니다.
저는 코딩에 대해 전혀 기초적인 베이스가 없는 사람이구요.
본격적으로 이 길을 가려고 하기보단 그냥 간단한 툴을 만들어서 이 구간을 깨고 싶은 사람입니다.
파이썬 기반이고요. 지금까지 짠 코드는 이렇습니다.
# FF7R Darts Helper v12.0 (FULL)
# - 투명 HUD 오버레이(클릭스루, 전체 화면)
# - 원형 ROI: F1(중심→가장자리 드래그식 2클릭), F2(중심/반지름 직접 입력)
# - 좌하단 상태창(노란 글씨), 우하단 Target Shape(선택 색상 미리보기)
# - 전역 단축키(RegisterHotKey): F1~F9, Ctrl+Alt+Q (게임 포커스와 무관)
# - WASD 자동 보정, Enter 자동 발사(최소 반경 통과 시)
# - 색상 픽(F7) & 연결성 기반 색상 추적(F8) — ROI 내부로 제한
# - 아이드롭퍼 .cur 시스템 커서 교체(실패 시 HUD 스포이드 PNG), 어떤 종료에도 복구 보장
import os, sys, time, threading, math, signal, atexit, queue, logging, pathlib
from collections import deque
import numpy as np
import cv2
import mss
from pynput.keyboard import Controller as KeyController, Key
from pynput.mouse import Controller as MouseController, Button
from pynput.mouse import Listener as MouseListener
from PyQt6 import QtCore, QtGui, QtWidgets
from ctypes import windll, wintypes
import ctypes
# ===================== 전역 설정 =====================
USE_BEEP = True
# 조준 보정(WASD) 파라미터
AIM_TOLERANCE = 4
PID_KP, PID_KI, PID_KD = 0.16, 0.00, 0.08
TAP_MIN_MS, TAP_MAX_MS = 14, 45
TAP_DEADZONE = 1.5
# 원(게이지) 탐지 파라미터
SMOOTH_N = 5
RADIUS_MIN_VALID = 6
HOUGH_DP, HOUGH_MIN_DIST = 1.2, 15
HOUGH_PARAM1, HOUGH_PARAM2 = 120, 20
MINR, MAXR = 5, 120
MIN_PEAK_GAP = 0.25
# 색상 추적(HSV)
COLOR_TOL_H, COLOR_TOL_S, COLOR_TOL_V = 10, 80, 80
MIN_COLOR_AREA = 18
MAX_JUMP_PX = 30
MORPH_K = np.ones((3,3), np.uint8)
# HUD 색/스타일
YELLOW = QtGui.QColor(255, 255, 0)
WHITE = QtGui.QColor(255, 255, 255)
PANEL_BG = QtGui.QColor(40, 40, 40, 180)
TARGET_BORDER = QtGui.QColor(255, 255, 255, 220)
ROI_COLOR = QtGui.QColor(255, 60, 60, 220) # 원형 ROI 테두리(빨강)
# 커서/스포이드 표시 정책
USE_SYSTEM_CURSOR = True # 실행 중 .cur로 시스템 커서를 바꾸기
SHOW_HUD_EYEDROPPER = False # HUD 스포이드 PNG도 따라다니게(겹치면 False 권장)
EYE_HOTSPOT = (16, 30) # HUD 스포이드 PNG 핫스팟 오프셋(px)
# ===================== 로깅 =====================
def setup_logging():
log_dir = pathlib.Path(os.getenv("LOCALAPPDATA", os.getcwd())) / "ff7_darts"
log_dir.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=str(log_dir / "ff7.log"),
level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s"
)
setup_logging()
def log_ex(ex: Exception):
try:
logging.exception(ex)
except:
pass
# ===================== 리소스 경로 =====================
def resource_path(rel_path: str) -> str:
if hasattr(sys, "_MEIPASS"):
return os.path.join(sys._MEIPASS, rel_path)
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, rel_path)
EYEDROPPER_PNG = resource_path("eyedropper.png")
EYEDROPPER_CUR = resource_path("eyedropper.cur")
# ===================== 전역 상태 =====================
kb = KeyController()
mouse = MouseController()
# 모니터 / ROI 초기값은 Qt로 읽어서 설정 (mss는 스레드 로컬에서 생성)
MON = {'left': 0, 'top': 0, 'width': 1920, 'height': 1080} # fallback
ROI_C = {'cx': 960, 'cy': 540, 'r': 360}
assist_enabled = True
autofire_enabled = True
target_pt = None # ROI-rect 좌표계 (x,y)
target_locked = False
smooth_q = deque(maxlen=SMOOTH_N)
prev_r = None
prev_dr_sign = None
last_trigger_t = 0.0
prev_time = time.time()
err_int_x = err_int_y = 0.0
prev_err_x = prev_err_y = 0.0
color_track_enabled = False
selected_hsv = None
seed_xy = None
last_centroid = None
last_circle_xy = None # 최근 검출 원 중심(ROI-rect)
last_radius_s = None # 스무딩 반경
eyedrop_img = None # HUD 스포이드 PNG (있으면 표시)
running = True
state_lock = threading.Lock()
stop_event = threading.Event()
gui_app: QtWidgets.QApplication | None = None # GUI 종료 신호용
# ===================== 유틸 =====================
def beep():
if not USE_BEEP: return
try:
import winsound
winsound.Beep(1850, 70)
except:
pass
def clip(v, lo, hi): return max(lo, min(hi, v))
def short_tap(axis, ms, sign):
"""WASD 전송: 축/길이/부호(방향)로 짧은 탭"""
ms = clip(ms, TAP_MIN_MS, TAP_MAX_MS)
key = ('d' if sign > 0 else 'a') if axis == 'x' else ('s' if sign > 0 else 'w')
try:
kb.press(key); time.sleep(ms/1000.0); kb.release(key)
except Exception as e:
print("[Tap] failed", e)
def calc_error(cur_xy, tgt_xy):
if cur_xy is None or tgt_xy is None: return None, None
cx, cy = cur_xy; tx, ty = tgt_xy
return (tx - cx), (ty - cy)
def pid_hold(dx, dy, dt):
"""오차 기반 짧은 탭 반복으로 보정"""
global err_int_x, err_int_y, prev_err_x, prev_err_y
ax = dx if abs(dx) > TAP_DEADZONE else 0.0
ay = dy if abs(dy) > TAP_DEADZONE else 0.0
err_int_x += ax * dt; err_int_y += ay * dt
derr_x = (ax - prev_err_x) / dt if dt > 0 else 0.0
derr_y = (ay - prev_err_y) / dt if dt > 0 else 0.0
tap_x_ms = (PID_KP*abs(ax) + PID_KI*abs(err_int_x) + PID_KD*abs(derr_x))*18.0 if ax != 0 else 0
tap_y_ms = (PID_KP*abs(ay) + PID_KI*abs(err_int_y) + PID_KD*abs(derr_y))*18.0 if ay != 0 else 0
if ax != 0: short_tap('x', tap_x_ms, 1 if dx > 0 else -1)
if ay != 0: short_tap('y', tap_y_ms, 1 if dy > 0 else -1)
prev_err_x, prev_err_y = ax, ay
# ===================== 커서(.cur) 교체/복구 =====================
_user32 = windll.user32
IMAGE_CURSOR = 2
LR_LOADFROMFILE = 0x0010
SPI_SETCURSORS = 0x0057
OCR_IDS = [
32512, # IDC_ARROW
32513, # IBEAM
32515, # CROSS
32649, # HAND
32651, # HELP
32650, # APPSTARTING
32648, # NO
32645, # SIZENS
32644, # SIZEWE
32642, # SIZENWSE
32643, # SIZENESW
32646, # SIZEALL
32516, # UPARROW
]
def set_system_cursor_to_file(cur_path: str) -> bool:
if not os.path.isfile(cur_path):
print(f"[Cursor] 파일 없음: {cur_path}")
return False
_user32.LoadImageW.restype = wintypes.HANDLE
hcur = _user32.LoadImageW(None, cur_path, IMAGE_CURSOR, 0, 0, LR_LOADFROMFILE)
if not hcur:
print("[Cursor] LoadImageW 실패")
return False
ok_all = True
for cid in OCR_IDS:
ok = _user32.SetSystemCursor(hcur, cid)
if not ok: ok_all = False
if ok_all: print("[Cursor] 전역 아이드롭퍼 적용")
else: print("[Cursor] 일부 커서 적용 실패")
return ok_all
def reset_system_cursors():
try:
_user32.SystemParametersInfoW(SPI_SETCURSORS, 0, None, 0)
print("[Cursor] 시스템 커서 복구")
except Exception as e:
print("[Cursor] 복구 실패:", e)
# 어떤 종료 경로든 복구 보장
atexit.register(reset_system_cursors)
def _signal_handler(sig, frame):
try: reset_system_cursors()
finally: os._exit(0)
for _sig in (signal.SIGINT, signal.SIGTERM):
try: signal.signal(_sig, _signal_handler)
except: pass
try: signal.signal(signal.SIGBREAK, _signal_handler)
except: pass
def request_quit():
"""전역 종료 요청 (Qt 이벤트 루프로 안전하게 종료)"""
stop_event.set()
if gui_app:
QtCore.QTimer.singleShot(0, gui_app.quit)
# ===================== ROI/좌표 도우미 =====================
def roi_bounding_rect_from_circle(cx, cy, r, mon=None):
"""원형 ROI의 외접 사각형 (모니터 경계로 클램프)"""
if mon is None: mon = MON
left = int(cx - r); top = int(cy - r)
width = int(2*r); height = int(2*r)
left = clip(left, mon['left'], mon['left'] + mon['width'] - 1)
top = clip(top, mon['top'], mon['top'] + mon['height'] - 1)
if left + width > mon['left'] + mon['width']: width = mon['left'] + mon['width'] - left
if top + height > mon['top'] + mon['height']: height = mon['top'] + mon['height'] - top
return {'left': left, 'top': top, 'width': width, 'height': height}
def point_in_circle_abs(x, y, cx, cy, r):
return (x-cx)*(x-cx) + (y-cy)*(y-cy) <= r*r
def circular_mask_for_rect(h, w, cx_abs, cy_abs, r, rect):
yy, xx = np.ogrid[:h, :w]
cx_r = cx_abs - rect['left']
cy_r = cy_abs - rect['top']
dist2 = (xx - cx_r)**2 + (yy - cy_r)**2
return dist2 <= (r*r)
# ===================== 입력(마우스 클릭 수집) =====================
def wait_for_left_click():
"""전역 마우스 왼클릭 좌표 1회 수집 (pynput Listener 사용)"""
pos = {"xy": None}
def on_click(x, y, button, pressed):
if pressed and button == Button.left:
pos["xy"] = (x, y); return False
with MouseListener(on_click=on_click) as listener:
listener.join()
return pos["xy"]
def set_roi_circle_by_drag():
"""F1: 중심 클릭 → 가장자리 클릭으로 반지름 산출"""
print("[ROI] 중심점 클릭...")
c = wait_for_left_click()
if c is None: print("[ROI] 취소"); return
print("[ROI] 가장자리(반지름) 점 클릭...")
e = wait_for_left_click()
if e is None: print("[ROI] 취소"); return
cx, cy = c; ex, ey = e
r = int(math.hypot(ex - cx, ey - cy))
with state_lock:
ROI_C['cx'], ROI_C['cy'], ROI_C['r'] = cx, cy, max(5, r)
print(f"[ROI] Circle center=({ROI_C['cx']},{ROI_C['cy']}), r={ROI_C['r']}")
def set_roi_circle_by_input():
"""F2: Tk 입력창으로 중심/반지름 직접 지정"""
import tkinter as tk
from tkinter import simpledialog, messagebox
root = None
try:
root = tk.Tk(); root.withdraw()
cx = simpledialog.askinteger("ROI Circle", "Center X", parent=root, minvalue=0)
if cx is None: return
cy = simpledialog.askinteger("ROI Circle", "Center Y", parent=root, minvalue=0)
if cy is None: return
r = simpledialog.askinteger("ROI Circle", "Radius", parent=root, minvalue=1)
if r is None: return
with state_lock:
ROI_C['cx'], ROI_C['cy'], ROI_C['r'] = cx, cy, r
print(f"[ROI] Circle center=({cx},{cy}), r={r}")
except Exception as e:
try: messagebox.showerror("ROI 입력 오류", str(e))
except: print("[ROI] 입력 오류:", e)
finally:
try:
if root is not None: root.destroy()
except: pass
def save_target_from_mouse(mon=None):
"""F3: 현재 마우스 좌표를 ROI-rect 좌표로 변환하여 target 잠금"""
if mon is None: mon = MON
mx, my = mouse.position
if not point_in_circle_abs(mx, my, ROI_C['cx'], ROI_C['cy'], ROI_C['r']):
print("[Target] 원형 ROI 밖"); return None
rect = roi_bounding_rect_from_circle(ROI_C['cx'], ROI_C['cy'], ROI_C['r'], mon)
tx, ty = mx - rect['left'], my - rect['top']
if 0 <= tx < rect['width'] and 0 <= ty < rect['height']:
return (int(tx), int(ty))
return None
# ===================== 비전: 원 검출/색상 추적 =====================
def detect_aim_circle(gray):
c = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp=HOUGH_DP, minDist=HOUGH_MIN_DIST,
param1=HOUGH_PARAM1, param2=HOUGH_PARAM2,
minRadius=MINR, maxRadius=MAXR)
if c is None: return None
c = np.uint16(np.around(c))[0]
idx = np.argmin(c[:,2]) # 가장 작은 원(게이지)
x, y, r = int(c[idx][0]), int(c[idx][1]), int(c[idx][2])
return None if r < RADIUS_MIN_VALID else (x, y, r)
def smooth_radius(r):
smooth_q.append(r)
return sum(smooth_q)/len(smooth_q) if smooth_q else r
def pick_color_under_mouse(frame_bgra, rect):
"""F7: ROI 내부에서 마우스 아래 픽셀 HSV 저장 + seed 지정"""
global selected_hsv, seed_xy, last_centroid, target_locked
mx, my = mouse.position
px = mx - rect['left']; py = my - rect['top']
if not (0 <= px < rect['width'] and 0 <= py < rect['height']):
print("[ColorPick] ROI-rect 밖"); return False
b, g, r = frame_bgra[py, px][:3]
hsv = cv2.cvtColor(np.uint8([[[b, g, r]]]), cv2.COLOR_BGR2HSV)[0][0]
selected_hsv = (int(hsv[0]), int(hsv[1]), int(hsv[2]))
seed_xy = (int(px), int(py))
last_centroid = None
target_locked = True
print(f"[ColorPick] HSV={selected_hsv} @ {seed_xy}")
return True
def detect_color_target_connected(frame_bgra, rect):
"""시드와 연결된 성분만(원형 ROI 내부 제한) 추적 → 중심(ROI-rect 좌표)"""
global last_centroid
if selected_hsv is None or seed_xy is None: return None
h, w = frame_bgra.shape[:2]
mask_circle = circular_mask_for_rect(h, w, ROI_C['cx'], ROI_C['cy'], ROI_C['r'], rect)
bgr = frame_bgra[:, :, :3]
hsv_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
h0, s0, v0 = selected_hsv
low = np.array([max(0, h0 - COLOR_TOL_H), max(0, s0 - COLOR_TOL_S), max(0, v0 - COLOR_TOL_V)])
high = np.array([min(179, h0 + COLOR_TOL_H), min(255, s0 + COLOR_TOL_S), min(255, v0 + COLOR_TOL_V)])
mask = cv2.inRange(hsv_img, low, high)
mask[~mask_circle] = 0
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, MORPH_K)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, MORPH_K)
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
if num_labels <= 1: return None
sx, sy = seed_xy
if not (0 <= sx < labels.shape[1] and 0 <= sy < labels.shape[0]): return None
seed_label = labels[sy, sx]
if seed_label == 0: return None
if stats[seed_label, cv2.CC_STAT_AREA] < MIN_COLOR_AREA: return None
cx, cy = centroids[seed_label]; cx, cy = int(cx), int(cy)
if last_centroid is not None and np.hypot(cx - last_centroid[0], cy - last_centroid[1]) > MAX_JUMP_PX:
return last_centroid
last_centroid = (cx, cy)
return last_centroid
def autofire_on_minimum(r_s, cur_xy):
"""반경이 하강→상승으로 바뀌는 극소점 통과 + 조준 오차 허용 → Enter"""
global prev_r, prev_dr_sign, last_trigger_t
if prev_r is not None:
dr = r_s - prev_r
sign = 1 if dr > 0 else (-1 if dr < 0 else 0)
if prev_dr_sign is not None and prev_dr_sign < 0 and sign > 0:
t = time.time()
if t - last_trigger_t > MIN_PEAK_GAP and target_pt is not None and target_locked:
dx, dy = calc_error(cur_xy, target_pt)
if dx is not None and abs(dx) <= AIM_TOLERANCE and abs(dy) <= AIM_TOLERANCE:
try:
kb.press(Key.enter); kb.release(Key.enter)
beep(); print("[AUTO] THROW!")
except Exception as e:
print("[AUTO] Enter 실패:", e)
last_trigger_t = t
prev_dr_sign = sign
prev_r = r_s
def reset_circle_state():
global prev_r, prev_dr_sign, target_pt, target_locked, last_centroid, last_circle_xy, last_radius_s
prev_r=None; prev_dr_sign=None
target_pt=None; target_locked=False
last_centroid=None; last_circle_xy=None; last_radius_s=None
# ===================== 전역 단축키(RegisterHotKey) =====================
user32 = ctypes.windll.user32
WM_HOTKEY = 0x0312
MOD_ALT=0x0001; MOD_CTRL=0x0002
VK_F1=0x70;VK_F2=0x71;VK_F3=0x72;VK_F4=0x73
VK_F5=0x74;VK_F6=0x75;VK_F7=0x76;VK_F8=0x77;VK_F9=0x78
VK_Q=0x51
class HotkeyManager:
def __init__(self):
self.events = queue.Queue()
self._running = threading.Event()
def _register(self,mod,vk,id_):
if not user32.RegisterHotKey(None,id_,mod,vk):
raise RuntimeError(f"RegisterHotKey fail: id={id_}")
def _unregister_all(self):
for i in range(1,20): user32.UnregisterHotKey(None,i)
def start(self):
self._register(0,VK_F1,1); self._register(0,VK_F2,2)
self._register(0,VK_F3,3); self._register(0,VK_F4,4)
self._register(0,VK_F5,5); self._register(0,VK_F6,6)
self._register(0,VK_F7,7); self._register(0,VK_F8,8)
self._register(0,VK_F9,9); self._register(MOD_CTRL|MOD_ALT,VK_Q,10)
self._running.set()
threading.Thread(target=self._loop,daemon=True).start()
print("[Hotkeys] registered")
def stop(self):
self._running.clear(); self._unregister_all()
print("[Hotkeys] unregistered")
def _loop(self):
msg = wintypes.MSG()
while self._running.is_set():
ret = user32.GetMessageW(ctypes.byref(msg),0,0,0)
if ret<=0: break
if msg.message==WM_HOTKEY:
try: self.events.put_nowait(int(msg.wParam))
except: pass
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
# ===================== HUD 오버레이 =====================
class Overlay(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("FF7 Darts HUD")
self.setWindowFlags(
QtCore.Qt.WindowType.FramelessWindowHint |
QtCore.Qt.WindowType.WindowStaysOnTopHint |
QtCore.Qt.WindowType.Tool
)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setWindowFlag(QtCore.Qt.WindowType.WindowTransparentForInput, True)
self.setWindowFlag(QtCore.Qt.WindowType.WindowDoesNotAcceptFocus, True)
screen = QtGui.QGuiApplication.primaryScreen()
self.setGeometry(screen.geometry())
self.timer = QtCore.QTimer(self)
self.timer.timeout.connect(self.update)
self.timer.start(16)
# 스포이드 PNG 로드
global eyedrop_img
if os.path.isfile(EYEDROPPER_PNG):
qimg = QtGui.QImage(EYEDROPPER_PNG)
if not qimg.isNull():
eyedrop_img = qimg
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
with state_lock:
cx, cy, r = ROI_C['cx'], ROI_C['cy'], ROI_C['r']
a_on, f_on, c_on = assist_enabled, autofire_enabled, color_track_enabled
circle_xy = last_circle_xy
radius_s = last_radius_s
s_hsv = selected_hsv
show_eyed = SHOW_HUD_EYEDROPPER
# ROI 원(빨강)
painter.setPen(QtGui.QPen(ROI_COLOR, 2))
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
painter.drawEllipse(QtCore.QPoint(cx, cy), r, r)
# 검출 원(녹색)
if circle_xy is not None and radius_s is not None:
rect = roi_bounding_rect_from_circle(cx, cy, r)
ccx = rect['left'] + int(circle_xy[0])
ccy = rect['top'] + int(circle_xy[1])
painter.setPen(QtGui.QPen(QtGui.QColor(0,255,0,220), 2))
painter.drawEllipse(QtCore.QPoint(ccx, ccy), int(radius_s), int(radius_s))
# 좌하단 상태창
self.draw_status_panel(painter, a_on, f_on, c_on, cx, cy, r, show_eyed)
# 우하단 Target Shape (선택 HSV 미리보기)
if s_hsv is not None:
self.draw_target_shape(painter, s_hsv)
# HUD 스포이드 (옵션)
if show_eyed and eyedrop_img is not None:
pos = QtGui.QCursor.pos()
x = pos.x() - EYE_HOTSPOT[0]; y = pos.y() - EYE_HOTSPOT[1]
painter.drawImage(x, y, eyedrop_img)
painter.end()
def draw_status_panel(self, painter, assist_on, fire_on, color_on, cx, cy, r, show_hud_cursor):
lines = [
f"Assist (WASD): {'ON' if assist_on else 'OFF'}",
f"AutoFire(Enter): {'ON' if fire_on else 'OFF'}",
f"ColorTrack : {'ON' if color_on else 'OFF'}",
f"Target Lock : {'LOCKED' if target_locked else 'OFF'}",
f"HUD Eyedrop : {'ON' if show_hud_cursor else 'OFF'} (F9)",
f"ROI Center=({cx},{cy}) R={r}",
"Hotkeys: F1 SetROI(Click-Click), F2 ROIInput, F3 SetTarget",
" F4 Assist, F5 AutoFire, F6 Clear, F7 PickColor, F8 Track, F9 HUDcursor",
"Exit: Ctrl+Alt+Q",
]
pad = 10; line_h = 22
box_w = 640; box_h = pad*2 + line_h*len(lines)
x0, y0 = 20, self.height() - box_h - 20
rect = QtCore.QRect(x0, y0, box_w, box_h)
painter.setPen(QtGui.QPen(QtCore.Qt.GlobalColor.transparent))
painter.setBrush(QtGui.QBrush(PANEL_BG)); painter.drawRect(rect)
painter.setPen(QtGui.QPen(TARGET_BORDER, 1)); painter.setBrush(QtCore.Qt.BrushStyle.NoBrush); painter.drawRect(rect)
painter.setPen(QtGui.QPen(YELLOW)); painter.setFont(QtGui.QFont("Segoe UI", 10))
for i, text in enumerate(lines):
painter.drawText(x0+10, y0+pad + (i+1)*line_h, text)
def draw_target_shape(self, painter, hsv_color):
box_w, box_h = 180, 110
x1 = self.width() - box_w - 20
y1 = self.height() - box_h - 20
rect = QtCore.QRect(x1, y1, box_w, box_h)
painter.setPen(QtGui.QPen(QtCore.Qt.GlobalColor.transparent))
painter.setBrush(QtGui.QBrush(PANEL_BG)); painter.drawRect(rect)
painter.setPen(QtGui.QPen(TARGET_BORDER, 2)); painter.setBrush(QtCore.Qt.BrushStyle.NoBrush); painter.drawRect(rect)
painter.setPen(QtGui.QPen(WHITE)); painter.setFont(QtGui.QFont("Segoe UI", 10))
painter.drawText(x1+10, y1+25, "Target Shape")
# HSV → BGR
bgr = cv2.cvtColor(np.uint8([[list(hsv_color)]]), cv2.COLOR_HSV2BGR)[0][0].tolist()
b, g, r = int(bgr[0]), int(bgr[1]), int(bgr[2])
painter.setPen(QtGui.QPen(WHITE, 2))
painter.setBrush(QtGui.QBrush(QtGui.QColor(r, g, b)))
center = QtCore.QPoint(x1 + box_w//2, y1 + box_h//2 + 15)
painter.drawEllipse(center, 22, 22)
# ===================== 로직 스레드 =====================
def logic_loop(hotkeys: 'HotkeyManager'):
"""캡처/분석/입력/핫키 처리 — 모두 이 스레드에서 수행 (mss는 여기서 생성!)"""
global assist_enabled, autofire_enabled, target_pt, target_locked
global prev_time, last_circle_xy, last_radius_s, selected_hsv, seed_xy
global color_track_enabled, SHOW_HUD_EYEDROPPER, MON
try:
# mss 는 스레드 로컬! → 여기서 생성/사용
sct = mss.mss()
mon = sct.monitors[1] # 주 모니터 사각형
MON = {'left': mon['left'], 'top': mon['top'], 'width': mon['width'], 'height': mon['height']}
print(f"[mss] monitor: {MON}")
def capture_roi_rect():
rect = roi_bounding_rect_from_circle(ROI_C['cx'], ROI_C['cy'], ROI_C['r'], MON)
return np.array(sct.grab(rect)), rect
while running and not stop_event.is_set():
# 1) 전역 단축키 처리 (비블로킹)
try:
while True:
hid = hotkeys.events.get_nowait()
if hid == 1: # F1: ROI 드래그식 설정
set_roi_circle_by_drag()
smooth_q.clear(); reset_circle_state()
elif hid == 2: # F2: ROI 입력
set_roi_circle_by_input()
smooth_q.clear(); reset_circle_state()
elif hid == 3: # F3: 현재 마우스 지점 타깃 잠금
t = save_target_from_mouse(MON)
if t is not None:
with state_lock:
target_pt = t; target_locked = True; last_centroid = t
print(f"[Target] {t} LOCKED")
else:
print("[Target] 선택 실패(ROI 밖?)")
elif hid == 4: # F4: 보정 토글
assist_enabled = not assist_enabled
print("[Assist]", "ON" if assist_enabled else "OFF")
elif hid == 5: # F5: 자동발사 토글
autofire_enabled = not autofire_enabled
print("[AutoFire]", "ON" if autofire_enabled else "OFF")
elif hid == 6: # F6: 타깃/추적 클리어
target_pt=None; target_locked=False; seed_xy=None; selected_hsv=None; last_centroid=None
print("[Target] cleared")
elif hid == 7: # F7: 색상 픽
frame, rect = capture_roi_rect()
# ROI 내부 클릭 검증
mx, my = mouse.position
if point_in_circle_abs(mx, my, ROI_C['cx'], ROI_C['cy'], ROI_C['r']):
if pick_color_under_mouse(frame, rect):
color_track_enabled = True
print("[ColorTrack] enabled & locked")
else:
print("[ColorPick] ROI 밖")
elif hid == 8: # F8: 색상 추적 토글
color_track_enabled = not color_track_enabled
print("[ColorTrack]", "ON" if color_track_enabled else "OFF")
elif hid == 9: # F9: HUD 스포이드 PNG 토글
SHOW_HUD_EYEDROPPER = not SHOW_HUD_EYEDROPPER
print("[HUD Eyedrop]", "ON" if SHOW_HUD_EYEDROPPER else "OFF")
elif hid == 10: # Ctrl+Alt+Q: 종료
request_quit()
except queue.Empty:
pass
# 2) 프레임 캡처 & ROI 마스킹
frame, rect = capture_roi_rect() # BGRA
gray = cv2.cvtColor(frame, cv2.COLOR_BGRA2GRAY)
mask = circular_mask_for_rect(rect['height'], rect['width'], ROI_C['cx'], ROI_C['cy'], ROI_C['r'], rect)
gray[~mask] = 0
# 3) 원(게이지) 탐지
gray_blur = cv2.GaussianBlur(gray, (5,5), 1.2)
c = detect_aim_circle(gray_blur)
if c is None:
with state_lock:
last_circle_xy = None; last_radius_s = None
time.sleep(0.006); continue
cx, cy, rdet = c
r_s = smooth_radius(rdet)
with state_lock:
last_circle_xy = (cx, cy)
last_radius_s = r_s
# 4) 색상 추적으로 타깃 갱신(있으면)
if color_track_enabled and selected_hsv is not None:
pt = detect_color_target_connected(frame, rect)
if pt is not None:
with state_lock:
target_pt = pt; target_locked = True
# 5) 자동 보정(WASD 탭)
dt = time.time() - prev_time; prev_time = time.time()
if assist_enabled and target_pt is not None and target_locked:
dx, dy = calc_error((cx, cy), target_pt)
if dx is not None and (abs(dx) > AIM_TOLERANCE or abs(dy) > AIM_TOLERANCE):
pid_hold(dx, dy, dt)
# 6) 자동 발사(최소 반경 통과)
if autofire_enabled:
autofire_on_minimum(r_s, (cx, cy))
time.sleep(0.006)
except Exception as e:
print("[logic_loop] crash:", e)
log_ex(e)
stop_event.set()
# ===================== 메인 =====================
def main():
global gui_app, MON, ROI_C
# 시스템 커서 아이드롭퍼 적용(실패 시 HUD 스포이드로 대체)
applied = False
if USE_SYSTEM_CURSOR:
applied = set_system_cursor_to_file(EYEDROPPER_CUR)
if not applied:
print("[Cursor] 시스템 커서 교체 실패 → HUD 스포이드로 대체 표시")
# SHOW_HUD_EYEDROPPER = True # 필요 시 자동 표시
# Qt 앱/스크린 지오메트리로 ROI 초기값 보정
app = QtWidgets.QApplication(sys.argv)
gui_app = app
scr = QtGui.QGuiApplication.primaryScreen().geometry()
MON = {'left': scr.left(), 'top': scr.top(), 'width': scr.width(), 'height': scr.height()}
ROI_C = {
'cx': MON['left'] + MON['width']//2,
'cy': MON['top'] + MON['height']//2,
'r': min(MON['width'], MON['height'])//3
}
# 전역 단축키 시작
hotkeys = HotkeyManager()
try:
hotkeys.start()
except Exception as e:
print("[Hotkeys] start fail:", e)
# HUD 시작
overlay = Overlay()
overlay.showFullScreen()
# 로직 스레드 시작
t = threading.Thread(target=logic_loop, args=(hotkeys,), daemon=True)
t.start()
# Qt 루프
ret = 0
try:
ret = app.exec()
finally:
# 종료 정리
stop_event.set()
try: t.join(timeout=1.0)
except: pass
try: hotkeys.stop()
except: pass
reset_system_cursors()
sys.exit(ret)
if __name__ == "__main__":
main()
문제는 Q나 Ctrl+Alt+Q를 누르면 정상적으로 종료가 되어야 할 툴이 강제 종료를 해야만 종료가 되고요.
정상적으로 종료하지 않을 시 마우스 포인터로 지정했던 eyedropper 이미지로 변한 마우스 커서가 원래의 마우스 커서로 돌아오지 않는다는 문제가 있고요.
오버레이로 툴이 켜지긴 하나 기능키들이 이 툴에 우선권으로 들어가지 않는다는 문제가 있습니다.
어떻게 해결해야 할까요? 급합니다.