이번엔 제가 사용하고 있는 아이템 표시(Alt)여부 확인용 애드온 공유입니다.

어제 올린다는 걸 깜빡했네요!


매번 방 입장시 아이템표시가 비활성화 되는 건 익숙하긴 합니다.

하지만, 아이템 필터가 나오고 나서는 필터링을 빡빡하게 하다보니 떨어지는 템이 잘 없고

은근 아이템 표시를 눌렀나 안 눌렀나 헷갈릴 때가 있어 만들어봤습니다.


대충 어떤 기능인지는 위의 영상을 참고하시면 되겠습니다.




[ 다운로드 및 설정법 ]

1. 구글 드라이브에서 'D2R_Reveal_v0.11.zip' 다운로드

2. 압축을 풀면 Assets 폴더, D2R_Reveal_v0.11.exe가 나옵니다.
    
3. Assets 폴더를 열어 아래와 같이 이미지를 세팅 합니다.
   ※ 기본적으로 4개의 이미지 파일이 들어가있는데, 참고용입니다. 아래 가이드대로 캡쳐하여 덮어 씌우시
      거나 모두 삭제하고 새로 이미지를 만들어 주세요.
   ※ 이미지를 예시처럼 작게 오려주세요. 너무 많은 부분을 오리면 감지하는 데 오래 걸릴 수 있습니다.
   ※ 수정된 이미지는 반드시 Asset 폴더 안에 들어있어야 합니다. 물론 파일명도 아래와 같아야 합니다.

   (1) lobby_ref.png
       
       ▲ 캐릭터 선택창에서 변하지 않는 부분을 캡쳐도구(Win+Shift+S)로 오려서 lobby_ref.png로 저장합니다.
 
   (2) lobby2_ref.png
        
       ▲ 대기실에서 변하지 않는 부분을 캡쳐도구(Win+Shift+S)로 오려서 lobby2_ref.png로 저장합니다.
        // 중앙의 퍼펙트 보석의 이미지를 따로 바꾸신 게 아니라면 이건 그대로 쓰셔도 무방해보입니다.

   (3) loading_ref.png
       
       ▲ 로딩화면에서 변하지 않는 부분을 캡쳐도구(Win+Shift+S)로 오려서 loading_ref.png로 저장합니다.
       // 이 부분이 식별이 빠른 거 같습니다. 폰트가 각자 달라서 각자 새로 찍긴 하셔야 할 겁니다.

   (4) ingame_ref.png
       
       ▲ 인게임에서 변하지 않는 부분을 캡쳐도구(Win+Shift+S)로 오려서 ingame_ref.png로 저장합니다.

4. D2R_Reveal_v0.11.exe를 실행한 다음 설정(⚙️)에 들어가 Asset 폴더 경로를 설정해줍니다.

   ▲ ① ~ ⑥ 의 순서대로 설정해주세요.

(D2R Reveal v.11 추가) 설정(⚙️)에 아이템 표시 키 지정 추가

(1) 게임 실행 후 'ESC - 설정 - 조작 - 아이템 표시'에서 Alt를 비교적 다른 용도로 사용 안 하는 키로 변경
   // 인게임 상태일 때 Alt+Tab으로 외부에서 다른 작업을 할 경우 게임 밖에서 Alt가 눌리면 오작동 하게 됨

(2) D2R Reveal v.11 실행 - 설정(⚙️)의 ITEM SHOW KEY에 변경한 아이템 표시키 등록


▲ SET KEY 버튼을 누르면 초록색 불이 들어오며 PRESS KEY로 바뀝니다.  이 때 원하시는 단축키를 누르시고 
    저장하시면 됩니다.  전 Caps Lock으로 했어요!

5. 앱과 게임을 켜신 다음 Alt+Tab으로 앱을 불러와 원하는 위치에 두고 고정 핀(📌)을 눌러 고정, 투명도 조절
   하시면 정상 작동하는 것을 볼 수 있습니다.
   ★ 'ESC - 설정 - 조작 - 아이템 표시'에서 Alt를 Caps Lock으로 반드시 변경 ★
   // Alt+Tab으로 다른 작업하는 경우가 더러있어 비교적 사용량이 적은 Caps Lock으로 작동하게 했습니다.
   ▲ 4번 하단 참조





보안 및 안전 안내
  • 안전성을 고려한 단순 이미지 출력: 직접 아이템 표시 기능을 작동시키는 방법은 안티치트(Anti-Cheat)에 의한 제재 위험이 있다고 판단, 단순 텍스트와 색상만 출력하는 알리미 프로그램으로 설계했습니다 .
  • 소스코드 투명 공개: 혹시 모를 불안감이 있으신 분들을 위해 사용된 파이썬(Python) 소스코드를 하단에 그대로 첨부합니다. 의심스러우시다면 코드를 직접 검증하시거나 파이썬으로 직접 실행하셔도 좋습니다!
  • 미인증 개인 제작 파일 특성상 백신 프로그램이 오진할 수 있으나, 어떠한 악성 기능도 없는 청정 프로그램임을 보증합니다
-----------------------------------------------------------------------------------------------------------------------
※ 본 앱은 게임 데이터나 인게임 요소를 직접 조작하지 않는 안전한 방식으로 구동됩니다. 다만, 서드파티 프로그램      
   이용에 따른 100%의 안전성을 보장하기는 어렵습니다. 앱 사용으로 인해 발생할 수 있는 모든 불이익 
   및 결과에 한 책임은 사용자 본인에게 있으니, 이 점을 반드시 숙지하시고 신중하게 사용해 주시기 바랍니다.
-----------------------------------------------------------------------------------------------------------------------

▼ 클릭 시 펼쳐집니다.

Python Code 보기
"""
╔══════════════════════════════════════════════════════════╗
║         REVEAL - Diablo II: Resurrected Overlay          ║
║              Item Display State Notifier                  ║
╠══════════════════════════════════════════════════════════╣
║  REQUIRED LIBRARIES (run in terminal / cmd):             ║
║                                                          ║
║  pip install keyboard                                    ║
║  pip install opencv-python                               ║
║  pip install pyautogui                                   ║
║  pip install Pillow                                      ║
║  pip install pywin32                                     ║
║                                                          ║
╠══════════════════════════════════════════════════════════╣
║  HOW TO PREPARE REFERENCE IMAGES:                        ║
║                                                          ║
║  1. Run Diablo II: Resurrected                           ║
║  2. Go to the LOBBY — Character Select screen            ║
║     → Take a screenshot, crop a UNIQUE part of the      ║
║       character select screen (e.g. the "PLAY" button)   ║
║     → Save as:  assets/lobby_ref.png                     ║
║                                                          ║
║     Also go to the WAITING ROOM (game list / create)     ║
║     → Crop a unique part (e.g. "CREATE GAME" button)     ║
║     → Save as:  assets/lobby2_ref.png  (optional)        ║
║                                                          ║
║  3. Start a game and go IN-GAME                          ║
║     → Take a screenshot, crop the MANA ORB (blue orb)   ║
║       at bottom-right or the LIFE ORB at bottom-left     ║
║     → Save as:  assets/ingame_ref.png                    ║
║                                                          ║
║  4. When a game loads you see a LOADING screen           ║
║     → Crop a unique part of it (the bar or logo)         ║
║     → Save as:  assets/loading_ref.png  (optional)       ║
║                                                          ║
╠══════════════════════════════════════════════════════════╣
║  TO BUILD .EXE (after pip install pyinstaller):          ║
║                                                          ║
║  pyinstaller --onefile --windowed reveal_overlay.py      ║
║                                                          ║
║  The .exe will appear in the  dist/  folder.             ║
║  Assets 폴더는 설정창에서 직접 지정하세요.                ║
╚══════════════════════════════════════════════════════════╝
"""

import tkinter as tk
from tkinter import filedialog
import threading
import time
import os
import sys
import json

# ── 선택적 임포트 ──────────────────────────────────────────────
try:
    import keyboard
    KEYBOARD_AVAILABLE = True
except ImportError:
    KEYBOARD_AVAILABLE = False

try:
    import cv2
    import numpy as np
    import pyautogui
    CV_AVAILABLE = True
except ImportError:
    CV_AVAILABLE = False

try:
    import win32gui, win32con
    WIN32_AVAILABLE = True
except ImportError:
    WIN32_AVAILABLE = False

# ─────────────────────────────────────────────────────────────
#  설정 파일 경로: %APPDATA%reveal_overlaysettings.json
#  EXE/스크립트 위치와 완전히 독립 → 어디서 실행해도 유지됨
# ─────────────────────────────────────────────────────────────
def _settings_dir() -> str:
    appdata = os.environ.get("APPDATA") or os.path.expanduser("~")
    d = os.path.join(appdata, "reveal_overlay")
    os.makedirs(d, exist_ok=True)
    return d

SETTINGS_PATH = os.path.join(_settings_dir(), "settings.json")

DEFAULT_SETTINGS = {
    "x":         80,
    "y":         80,
    "pinned":    False,
    "opacity":   100,
    "asset_dir": "",      # 사용자가 설정창에서 직접 지정
}

def load_settings() -> dict:
    try:
        with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        for k, v in DEFAULT_SETTINGS.items():
            data.setdefault(k, v)
        return data
    except Exception:
        return dict(DEFAULT_SETTINGS)

def save_settings(data: dict):
    try:
        with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
    except Exception:
        pass

# ─────────────────────────────────────────────────────────────
#  앱 상태
# ─────────────────────────────────────────────────────────────
class AppState:
    LOBBY   = "LOBBY"
    INGAME  = "IN-GAME"
    LOADING = "LOADING"
    NO_REF  = "NO_REF"

    def __init__(self):
        self.item_show:  bool = False
        self.game_state: str  = self.LOBBY
        self.lock             = threading.Lock()

state = AppState()

# ─────────────────────────────────────────────────────────────
#  화면 감지 스레드
# ─────────────────────────────────────────────────────────────
def load_ref(asset_dir: str, name: str):
    """
    cv2.imread 는 Windows 에서 비ASCII(한국어 등) 경로를 읽지 못하는 버그가 있음.
    np.fromfile + cv2.imdecode 로 우회하면 유니코드 경로도 정상 동작.
    """
    path = os.path.join(asset_dir, name)
    if not os.path.exists(path):
        return None
    buf = np.fromfile(path, dtype=np.uint8)
    img = cv2.imdecode(buf, cv2.IMREAD_GRAYSCALE)
    return img  # None 이면 호출부에서 처리

def screen_watcher(asset_dir: str, update_cb, stop_event: threading.Event):
    INTERVAL  = 0.5
    THRESHOLD = 0.75

    if not CV_AVAILABLE:
        with state.lock:
            state.game_state = AppState.INGAME
        update_cb()
        return

    # asset_dir 미설정이면 바로 NO_REF
    if not asset_dir or not os.path.isdir(asset_dir):
        with state.lock:
            state.game_state = AppState.NO_REF
        update_cb()
        return

    # 기준 이미지 로드
    ref_ingame  = load_ref(asset_dir, "ingame_ref.png")
    ref_lobby   = load_ref(asset_dir, "lobby_ref.png")
    ref_lobby2  = load_ref(asset_dir, "lobby2_ref.png")
    ref_loading = load_ref(asset_dir, "loading_ref.png")

    if ref_ingame is None and ref_lobby is None and ref_lobby2 is None:
        with state.lock:
            state.game_state = AppState.NO_REF
        update_cb()
        return

    while not stop_event.is_set():
        try:
            screenshot = pyautogui.screenshot()
            gray = cv2.cvtColor(np.array(screenshot), cv2.COLOR_RGB2GRAY)

            def match(ref):
                if ref is None:
                    return 0.0
                result = cv2.matchTemplate(gray, ref, cv2.TM_CCOEFF_NORMED)
                return float(cv2.minMaxLoc(result)[1])

            score_loading = match(ref_loading)
            score_lobby   = match(ref_lobby)
            score_lobby2  = match(ref_lobby2)
            score_ingame  = match(ref_ingame)

            with state.lock:
                prev = state.game_state
                if score_loading >= THRESHOLD:
                    new_state = AppState.LOADING
                elif score_lobby >= THRESHOLD or score_lobby2 >= THRESHOLD:
                    new_state = AppState.LOBBY
                elif score_ingame >= THRESHOLD:
                    new_state = AppState.INGAME
                else:
                    new_state = prev

                if new_state == AppState.LOBBY and prev != AppState.LOBBY:
                    state.item_show = False

                state.game_state = new_state
                if new_state != prev:
                    update_cb()

        except Exception:
            pass

        stop_event.wait(INTERVAL)

# ─────────────────────────────────────────────────────────────
#  키 감지 스레드
# ─────────────────────────────────────────────────────────────
def key_watcher(update_cb):
    if not KEYBOARD_AVAILABLE:
        return
    ALT_NAMES = {"caps lock"}

    def on_key(e):
        if e.event_type != keyboard.KEY_DOWN:
            return
        if e.name not in ALT_NAMES:
            return
        with state.lock:
            if state.game_state != AppState.INGAME:
                return
            state.item_show = not state.item_show
        update_cb()

    keyboard.hook(on_key, suppress=False)
    keyboard.wait()

# ─────────────────────────────────────────────────────────────
#  색상 / 폰트
# ─────────────────────────────────────────────────────────────
C = {
    "bg":          "#0d0d0f",
    "bg2":         "#141418",
    "bg3":         "#1a1a20",
    "border":      "#2a2a35",
    "accent_gold": "#c8a45a",
    "text_dim":    "#5a5a6e",
    "text_main":   "#c0bdb8",
    "neon_green":  "#39ff6a",
    "neon_red":    "#ff3a3a",
    "pin_default": "#4a4a5a",
    "pin_active":  "#c8a45a",
    "titlebar":    "#0a0a0c",
}

FONT_TITLE  = ("Courier New", 11, "bold")
FONT_LABEL  = ("Courier New", 9)
FONT_STATUS = ("Courier New", 14, "bold")
FONT_STATE  = ("Courier New", 9, "bold")
FONT_SMALL  = ("Courier New", 8)

# ─────────────────────────────────────────────────────────────
#  설정 팝업 창
# ─────────────────────────────────────────────────────────────
class SettingsWindow:
    def __init__(self, parent_app):
        self._app = parent_app
        cfg = load_settings()

        win = tk.Toplevel(parent_app.root)
        self._win = win
        win.title("REVEAL — Settings")
        win.configure(bg=C["bg"])
        win.overrideredirect(True)
        win.attributes("-topmost", False)
        win.resizable(False, False)

        # 메인 창 기준으로 배치하되 화면 밖으로 나가지 않도록 보정
        pw = parent_app.root
        W, H = 340, 160   # 설정창 크기

        # 기본: 메인 창 수평 중앙, 바로 아래
        sx = pw.winfo_x() + pw.winfo_width() // 2 - W // 2
        sy = pw.winfo_y() + pw.winfo_height() + 4

        # 화면 크기 가져오기
        sw = win.winfo_screenwidth()
        sh = win.winfo_screenheight()

        # 오른쪽/왼쪽 경계 보정
        sx = max(0, min(sx, sw - W))
        # 아래쪽 공간 부족하면 메인 창 위로 띄우기
        if sy + H > sh:
            sy = pw.winfo_y() - H - 4
        # 그래도 위로 넘치면 그냥 화면 상단에 고정
        sy = max(0, sy)

        win.geometry(f"{W}x{H}+{sx}+{sy}")

        # 외곽 테두리
        outer = tk.Frame(win, bg=C["accent_gold"], bd=0)
        outer.pack(fill="both", expand=True, padx=1, pady=1)

        inner = tk.Frame(outer, bg=C["bg"], bd=0)
        inner.pack(fill="both", expand=True, padx=1, pady=1)

        # 타이틀바
        tbar = tk.Frame(inner, bg=C["titlebar"], height=26)
        tbar.pack(fill="x")
        tbar.pack_propagate(False)

        tk.Label(tbar, text="⚙  SETTINGS", font=FONT_LABEL,
                 fg=C["accent_gold"], bg=C["titlebar"], padx=8).pack(side="left")

        close_lbl = tk.Label(tbar, text="✕", font=("Courier New", 10, "bold"),
                             fg=C["text_dim"], bg=C["titlebar"], padx=8, cursor="hand2")
        close_lbl.pack(side="right")
        close_lbl.bind("<Enter>",    lambda e: close_lbl.config(fg=C["neon_red"]))
        close_lbl.bind("<Leave>",    lambda e: close_lbl.config(fg=C["text_dim"]))
        close_lbl.bind("<Button-1>", lambda e: win.destroy())

        # 드래그
        tbar.bind("<ButtonPress-1>", self._drag_start)
        tbar.bind("<B1-Motion>",     self._drag_move)

        # 구분선
        tk.Frame(inner, bg=C["border"], height=1).pack(fill="x")

        # ── 콘텐츠 ──────────────────────────────────────────
        body = tk.Frame(inner, bg=C["bg"], padx=14, pady=10)
        body.pack(fill="both", expand=True)

        # Assets 폴더
        tk.Label(body, text="ASSETS FOLDER", font=FONT_SMALL,
                 fg=C["text_dim"], bg=C["bg"]).grid(row=0, column=0, sticky="w", pady=(0, 4))

        path_frame = tk.Frame(body, bg=C["bg2"], highlightthickness=1,
                              highlightbackground=C["border"])
        path_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 10))
        body.columnconfigure(0, weight=1)

        self._path_var = tk.StringVar(value=cfg.get("asset_dir", ""))
        path_entry = tk.Entry(
            path_frame, textvariable=self._path_var,
            font=FONT_SMALL, fg=C["text_main"], bg=C["bg2"],
            insertbackground=C["accent_gold"],
            relief="flat", bd=4
        )
        path_entry.pack(side="left", fill="x", expand=True)

        browse_btn = tk.Label(
            path_frame, text="  …  ", font=FONT_SMALL,
            fg=C["accent_gold"], bg=C["border"], cursor="hand2"
        )
        browse_btn.pack(side="right")
        browse_btn.bind("<Button-1>", self._browse)

        # 상태 레이블 (저장 후 결과 표시)
        self._status_var = tk.StringVar(value="")
        self._status_lbl = tk.Label(body, textvariable=self._status_var,
                                    font=FONT_SMALL, fg=C["neon_green"], bg=C["bg"])
        self._status_lbl.grid(row=2, column=0, sticky="w")

        # 저장 버튼
        save_btn = tk.Label(
            body, text="  SAVE & APPLY  ",
            font=("Courier New", 9, "bold"),
            fg=C["bg"], bg=C["accent_gold"],
            cursor="hand2", pady=4
        )
        save_btn.grid(row=2, column=1, sticky="e")
        save_btn.bind("<Enter>", lambda e: save_btn.config(bg=C["neon_green"], fg=C["bg"]))
        save_btn.bind("<Leave>", lambda e: save_btn.config(bg=C["accent_gold"], fg=C["bg"]))
        save_btn.bind("<Button-1>", self._save)

        self._drag_x = self._drag_y = 0

    def _drag_start(self, e):
        self._drag_x = e.x_root - self._win.winfo_x()
        self._drag_y = e.y_root - self._win.winfo_y()

    def _drag_move(self, e):
        self._win.geometry(f"+{e.x_root - self._drag_x}+{e.y_root - self._drag_y}")

    def _browse(self, e=None):
        # 파일 탐색기가 설정창 뒤로 숨지 않도록 topmost 잠깐 해제
        self._win.attributes("-topmost", False)
        d = filedialog.askdirectory(
            title="Assets 폴더 선택",
            initialdir=self._path_var.get() or os.path.expanduser("~"),
            parent=self._win
        )
        self._win.attributes("-topmost", False)
        if d:
            self._path_var.set(d)
        self._win.lift()

    def _save(self, e=None):
        new_dir = self._path_var.get().strip()

        # 경로 유효성 확인
        ingame_ok = os.path.isfile(os.path.join(new_dir, "ingame_ref.png"))
        lobby_ok  = os.path.isfile(os.path.join(new_dir, "lobby_ref.png"))
        lobby2_ok = os.path.isfile(os.path.join(new_dir, "lobby2_ref.png"))

        if new_dir and not os.path.isdir(new_dir):
            self._status_var.set("⚠ 폴더를 찾을 수 없습니다.")
            self._status_lbl.config(fg=C["neon_red"])
            return

        # 설정 저장
        cfg = load_settings()
        cfg["asset_dir"] = new_dir
        save_settings(cfg)

        # 화면 감지 스레드 재시작
        self._app._restart_watcher(new_dir)

        if ingame_ok or lobby_ok or lobby2_ok:
            self._status_var.set("✔ 저장됨")
            self._status_lbl.config(fg=C["neon_green"])
        else:
            self._status_var.set("⚠ 저장됨. 기준 이미지를 찾을 수 없음")
            self._status_lbl.config(fg=C["accent_gold"])

# ─────────────────────────────────────────────────────────────
#  메인 앱
# ─────────────────────────────────────────────────────────────
class RevealApp:
    def __init__(self, root: tk.Tk):
        self.root    = root
        self._drag_x = self._drag_y = 0
        self._settings_win = None

        cfg = load_settings()
        self._pinned  = cfg["pinned"]
        self._opacity = cfg["opacity"] / 100.0

        self._setup_window(cfg)
        self._build_ui()
        self._apply_win32_style()

        # 핀 상태 복원
        if self._pinned:
            self.root.attributes("-topmost", True)
            self._pin_btn.config(fg=C["pin_active"])
            self._title_lbl.config(cursor="")
            self._topbar.config(cursor="")

        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

        # 스레드 시작
        self._watcher_stop   = threading.Event()
        self._watcher_thread = None
        asset_dir = cfg.get("asset_dir", "")
        self._watcher_thread = threading.Thread(
            target=screen_watcher,
            args=(asset_dir, self._schedule_update, self._watcher_stop),
            daemon=True
        )
        self._watcher_thread.start()
        threading.Thread(target=key_watcher,
                         args=(self._schedule_update,), daemon=True).start()

        self._update_ui()

    # ── 윈도우 설정 ─────────────────────────────────────────
    def _setup_window(self, cfg):
        r = self.root
        r.overrideredirect(True)   # 기본 타이틀바 제거
        r.configure(bg=C["bg"])
        r.attributes("-topmost", False)
        r.attributes("-alpha", self._opacity)
        r.geometry(f"320x100+{cfg['x']}+{cfg['y']}")
        r.resizable(False, False)
        r.minsize(320, 100)
        # withdraw/deiconify 는 _apply_win32_style 에서 처리

    def _apply_win32_style(self):
        """
        overrideredirect 창을 작업표시줄·Alt+Tab 에 표시하는 방법.

        핵심: overrideredirect(True) 직후엔 HWND가 WS_EX_TOOLWINDOW로 세팅되어
        작업표시줄에서 숨겨진다. 이를 WS_EX_APPWINDOW로 교체해야 한다.

        withdraw() → 스타일 변경 → deiconify() 순서로 해야
        Windows 탐색기가 변경을 인식해 작업표시줄 버튼을 추가한다.
        """
        if not WIN32_AVAILABLE:
            return

        import ctypes
        GWL_EXSTYLE      = -20
        WS_EX_APPWINDOW  = 0x00040000
        WS_EX_TOOLWINDOW = 0x00000080
        SWP_FLAGS = (0x0002 | 0x0001 | 0x0004 | 0x0020)  # NOMOVE|NOSIZE|NOZORDER|FRAMECHANGED

        self.root.withdraw()
        self.root.update_idletasks()

        hwnd = ctypes.windll.user32.GetParent(self.root.winfo_id())
        if hwnd == 0:
            hwnd = self.root.winfo_id()

        style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
        style = (style & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
        ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style)
        ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP_FLAGS)

        self.root.deiconify()

    # ── UI 빌드 ─────────────────────────────────────────────
    def _build_ui(self):
        r = self.root

        self._outer = tk.Frame(r, bg=C["border"], bd=0)
        self._outer.pack(fill="both", expand=True, padx=1, pady=1)

        inner = tk.Frame(self._outer, bg=C["bg"], bd=0)
        inner.pack(fill="both", expand=True, padx=1, pady=1)

        # 상단바
        self._topbar = tk.Frame(inner, bg=C["titlebar"], height=28, bd=0)
        topbar = self._topbar
        topbar.pack(fill="x")
        topbar.pack_propagate(False)
        topbar.bind("<ButtonPress-1>", self._drag_start)
        topbar.bind("<B1-Motion>",     self._drag_move)

        # 제목
        self._title_lbl = tk.Label(
            topbar, text="◆ REVEAL", font=FONT_TITLE,
            fg=C["accent_gold"], bg=C["titlebar"],
            cursor="fleur", padx=8
        )
        self._title_lbl.pack(side="left")
        self._title_lbl.bind("<ButtonPress-1>", self._drag_start)
        self._title_lbl.bind("<B1-Motion>",     self._drag_move)

        # 투명도 슬라이더
        op_frame = tk.Frame(topbar, bg=C["titlebar"])
        op_frame.pack(side="left", padx=(4, 0))
        tk.Label(op_frame, text="◀", font=("Courier New", 7),
                 fg=C["text_dim"], bg=C["titlebar"]).pack(side="left")
        self._opacity_slider = tk.Scale(
            op_frame, from_=100, to=10, orient="horizontal",
            length=70, showvalue=False, bd=0, highlightthickness=0,
            troughcolor=C["bg2"], bg=C["titlebar"], fg=C["accent_gold"],
            activebackground=C["accent_gold"], sliderrelief="flat",
            sliderlength=12, width=6, command=self._on_opacity_change
        )
        self._opacity_slider.set(int(self._opacity * 100))
        self._opacity_slider.pack(side="left")
        tk.Label(op_frame, text="▶", font=("Courier New", 7),
                 fg=C["text_dim"], bg=C["titlebar"]).pack(side="left")

        # 오른쪽 버튼 영역
        btn_frame = tk.Frame(topbar, bg=C["titlebar"])
        btn_frame.pack(side="right", padx=4)

        # 종료 버튼
        self._close_btn = tk.Label(
            btn_frame, text="✕", font=("Courier New", 11, "bold"),
            fg=C["text_dim"], bg=C["titlebar"], padx=6, pady=2, cursor="hand2"
        )
        self._close_btn.pack(side="right")
        self._close_btn.bind("<Enter>",    lambda e: self._close_btn.config(fg=C["neon_red"]))
        self._close_btn.bind("<Leave>",    lambda e: self._close_btn.config(fg=C["text_dim"]))
        self._close_btn.bind("<Button-1>", lambda e: self._on_close())

        # 고정 핀
        self._pin_btn = tk.Label(
            btn_frame, text="📌", font=("Courier New", 10),
            fg=C["pin_default"], bg=C["titlebar"], padx=4, pady=2, cursor="hand2"
        )
        self._pin_btn.pack(side="right")
        self._pin_btn.bind("<Button-1>", self._toggle_pin)

        # ⚙ 설정 버튼 (핀 왼쪽)
        self._gear_btn = tk.Label(
            btn_frame, text="⚙", font=("Courier New", 11),
            fg=C["text_dim"], bg=C["titlebar"], padx=4, pady=2, cursor="hand2"
        )
        self._gear_btn.pack(side="right")
        self._gear_btn.bind("<Enter>",    lambda e: self._gear_btn.config(fg=C["accent_gold"]))
        self._gear_btn.bind("<Leave>",    lambda e: self._gear_btn.config(fg=C["text_dim"]))
        self._gear_btn.bind("<Button-1>", lambda e: self._open_settings())

        # 구분선
        tk.Frame(inner, bg=C["border"], height=1).pack(fill="x")

        # 콘텐츠
        content = tk.Frame(inner, bg=C["bg"], pady=6)
        content.pack(fill="both", expand=True, padx=10)

        state_row = tk.Frame(content, bg=C["bg"])
        state_row.pack(fill="x", pady=(0, 4))
        tk.Label(state_row, text="STATUS :", font=FONT_LABEL,
                 fg=C["text_dim"], bg=C["bg"]).pack(side="left")
        self._state_lbl = tk.Label(
            state_row, text="LOBBY", font=FONT_STATE,
            fg=C["accent_gold"], bg=C["bg"]
        )
        self._state_lbl.pack(side="left", padx=(4, 0))

        self._status_frame = tk.Frame(content, bg=C["bg2"], height=36, bd=0)
        self._status_frame.pack(fill="x")
        self._status_frame.pack_propagate(False)

        self._indicator = tk.Frame(self._status_frame, width=5, bg=C["neon_red"])
        self._indicator.pack(side="left", fill="y")

        self._status_lbl = tk.Label(
            self._status_frame, text="ITEM SHOW : OFF",
            font=FONT_STATUS, fg=C["neon_red"], bg=C["bg2"], padx=12
        )
        self._status_lbl.pack(side="left", fill="both", expand=True)

    # ── 드래그 ──────────────────────────────────────────────
    def _drag_start(self, e):
        if self._pinned:
            return
        self._drag_x = e.x_root - self.root.winfo_x()
        self._drag_y = e.y_root - self.root.winfo_y()

    def _drag_move(self, e):
        if self._pinned:
            return
        self.root.geometry(f"+{e.x_root - self._drag_x}+{e.y_root - self._drag_y}")

    # ── 투명도 ──────────────────────────────────────────────
    def _on_opacity_change(self, val):
        self._opacity = max(0.1, int(val) / 100.0)
        self.root.attributes("-alpha", self._opacity)

    # ── 고정 핀 ─────────────────────────────────────────────
    def _toggle_pin(self, e=None):
        self._pinned = not self._pinned
        if self._pinned:
            self.root.attributes("-topmost", True)
            self._pin_btn.config(fg=C["pin_active"])
            self._title_lbl.config(cursor="")
            self._topbar.config(cursor="")
        else:
            self.root.attributes("-topmost", False)
            self._pin_btn.config(fg=C["pin_default"])
            self._title_lbl.config(cursor="fleur")
            self._topbar.config(cursor="fleur")

    # ── 설정창 ──────────────────────────────────────────────
    def _open_settings(self):
        # 이미 열려 있으면 포커스만
        if self._settings_win and self._settings_win._win.winfo_exists():
            self._settings_win._win.lift()
            return
        self._settings_win = SettingsWindow(self)

    # ── 감지 스레드 재시작 ──────────────────────────────────
    def _restart_watcher(self, asset_dir: str):
        # 1) 기존 스레드 종료 신호
        self._watcher_stop.set()

        # 2) 새 stop event 준비
        self._watcher_stop = threading.Event()
        new_stop = self._watcher_stop

        # 3) 상태 초기화
        with state.lock:
            state.game_state = AppState.LOBBY
            state.item_show  = False

        # 4) 기존 스레드 종료 대기 후 새 스레드 시작 (별도 스레드에서)
        def _launch():
            if self._watcher_thread and self._watcher_thread.is_alive():
                self._watcher_thread.join(timeout=1.5)
            if not new_stop.is_set():
                t = threading.Thread(
                    target=screen_watcher,
                    args=(asset_dir, self._schedule_update, new_stop),
                    daemon=True
                )
                t.start()
                self._watcher_thread = t

        threading.Thread(target=_launch, daemon=True).start()
        self._schedule_update()

    # ── 종료 ────────────────────────────────────────────────
    def _on_close(self):
        self._watcher_stop.set()
        cfg = load_settings()
        cfg.update({
            "x":       self.root.winfo_x(),
            "y":       self.root.winfo_y(),
            "pinned":  self._pinned,
            "opacity": int(self._opacity * 100),
        })
        save_settings(cfg)
        self.root.destroy()

    # ── UI 갱신 ─────────────────────────────────────────────
    def _schedule_update(self):
        self.root.after(0, self._update_ui)

    def _update_ui(self):
        with state.lock:
            gs   = state.game_state
            show = state.item_show

        state_colors = {
            AppState.INGAME:  ("#39ff6a", "▶ IN-GAME"),
            AppState.LOBBY:   (C["accent_gold"], "◈ LOBBY"),
            AppState.LOADING: ("#5ab4ff", "↺ LOADING…"),
            AppState.NO_REF:  (C["neon_red"], "⚠ NO ASSETS"),
        }
        sc, st = state_colors.get(gs, (C["text_dim"], gs))
        self._state_lbl.config(text=st, fg=sc)

        if show:
            self._status_lbl.config(text="ITEM SHOW : ON",  fg=C["neon_green"])
            self._indicator.config(bg=C["neon_green"])
            self._outer.config(bg=C["accent_gold"])
        else:
            self._status_lbl.config(text="ITEM SHOW : OFF", fg=C["neon_red"])
            self._indicator.config(bg=C["neon_red"])
            self._outer.config(bg=C["border"])

# ─────────────────────────────────────────────────────────────
#  진입점
# ─────────────────────────────────────────────────────────────
def main():
    root = tk.Tk()
    root.title("REVEAL")
    app = RevealApp(root)
    root.mainloop()

if __name__ == "__main__":
    main()