"""
╔══════════════════════════════════════════════════════════╗
║ 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()