"""Transparent click-through overlay: role tags + Top-3 recommend badges. Runs a Tk root on a background thread. DraftSession calls set_roster() and set_grid_marks(); geometry uses the same relative coords as recognition. """ from __future__ import annotations import json import sys import threading import tkinter as tk from pathlib import Path import mss from common import HEROES_JSON as HEROES_PATH, ROOT, slot_rect_px ROLE_ICONS_DIR = ROOT / "assets" / "role_icons" ROLE_ORDER = [ "Carry", "Support", "Nuker", "Disabler", "Durable", "Escape", "Initiator", "Pusher", ] CHROMA = "#ff00ff" DEFAULT_REC_COLOR = "#2ec4b6" DEFAULT_REC_TEXT = "#0b1220" def _load_roles_by_key() -> dict[str, list[str]]: if not HEROES_PATH.exists(): return {} table = json.loads(HEROES_PATH.read_text(encoding="utf-8")) return {h["key"]: list(h.get("roles") or []) for h in table} def _primary_monitor_size() -> tuple[int, int]: with mss.MSS() as sct: mon = sct.monitors[1] return int(mon["width"]), int(mon["height"]) def _enable_click_through(hwnd: int) -> None: """Make the window ignore mouse input (Windows).""" if sys.platform != "win32": return import ctypes user32 = ctypes.windll.user32 GWL_EXSTYLE = -20 WS_EX_LAYERED = 0x00080000 WS_EX_TRANSPARENT = 0x00000020 WS_EX_TOOLWINDOW = 0x00000080 get_long = user32.GetWindowLongW set_long = user32.SetWindowLongW style = get_long(hwnd, GWL_EXSTYLE) set_long(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW) class DraftOverlay: """Fullscreen transparent overlay drawn above Dota during draft tracking.""" def __init__(self, cfg: dict): self.cfg = cfg o = cfg.get("overlay") or {} self.y_gap_rel = float(o.get("y_gap_rel", 0.008)) self.icon_h_rel = float(o.get("icon_h_rel", 0.016)) self.icon_gap_rel = float(o.get("icon_gap_rel", 0.002)) self.mark_size_rel = float(o.get("mark_size_rel", 0.018)) self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004)) self.rec_color = str(o.get("rec_color", DEFAULT_REC_COLOR)) self.rec_text = str(o.get("rec_text_color", DEFAULT_REC_TEXT)) self.roles_by_key = _load_roles_by_key() self._roster: dict[int, str] = {} self._cells: dict[str, dict] = {} self._marks: dict[str, int] = {} self._ready = threading.Event() self._closed = False self._root: tk.Tk | None = None self._canvas: tk.Canvas | None = None self._photos: list[tk.PhotoImage] = [] self._icon_src: dict[str, tk.PhotoImage] = {} self._thread = threading.Thread(target=self._run, name="draft-overlay", daemon=True) self._thread.start() self._ready.wait(timeout=5.0) def _run(self) -> None: sw, sh = _primary_monitor_size() root = tk.Tk() self._root = root root.overrideredirect(True) root.attributes("-topmost", True) root.geometry(f"{sw}x{sh}+0+0") root.configure(bg=CHROMA) try: root.attributes("-transparentcolor", CHROMA) except tk.TclError: pass canvas = tk.Canvas(root, width=sw, height=sh, bg=CHROMA, highlightthickness=0, bd=0) canvas.pack(fill="both", expand=True) self._canvas = canvas self._screen = (sw, sh) self._load_icon_sources() root.update_idletasks() try: hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id()) if sys.platform == "win32": import ctypes hwnd = ctypes.windll.user32.GetParent(root.winfo_id()) or root.winfo_id() _enable_click_through(int(hwnd)) except Exception: pass root.withdraw() self._ready.set() root.mainloop() try: root.destroy() except tk.TclError: pass self._closed = True def _load_icon_sources(self) -> None: assert self._root is not None for name in ROLE_ORDER: path = ROLE_ICONS_DIR / f"{name}.png" if not path.exists(): continue try: self._icon_src[name] = tk.PhotoImage(master=self._root, file=str(path)) except tk.TclError: continue def _scaled_icon(self, name: str, target_h: int) -> tk.PhotoImage | None: src = self._icon_src.get(name) if src is None or target_h <= 0: return None h = max(src.height(), 1) if target_h >= h: factor = max(1, round(target_h / h)) img = src.zoom(factor, factor) else: factor = max(1, round(h / target_h)) img = src.subsample(factor, factor) self._photos.append(img) return img def set_roster(self, confirmed: dict[int, str]) -> None: """Update tags for confirmed slots (hero_key by slot index).""" roster = {int(k): v for k, v in confirmed.items() if v} if roster == self._roster: return self._roster = dict(roster) self._schedule_redraw() def set_grid_marks( self, cells: dict[str, dict] | None, marks: dict[str, int] | list[dict] | None, ) -> None: """Cyan Top-3 badges on hero-grid cells. marks: key->rank or suggest_top list.""" cells = {str(k): dict(v) for k, v in (cells or {}).items()} parsed: dict[str, int] = {} if isinstance(marks, list): for item in marks: key = item.get("key") rank = item.get("rank") if key and rank: parsed[str(key)] = int(rank) elif marks: for key, rank in marks.items(): parsed[str(key)] = int(rank) if cells == self._cells and parsed == self._marks: return self._cells = cells self._marks = parsed self._schedule_redraw() def _schedule_redraw(self) -> None: root = self._root if root is None or self._closed: return try: root.after(0, self._redraw) except RuntimeError: pass def show(self) -> None: root = self._root if root is None or self._closed: return try: root.after(0, root.deiconify) except RuntimeError: pass def hide(self) -> None: root = self._root if root is None or self._closed: return def _hide() -> None: if self._canvas is not None: self._canvas.delete("all") self._photos.clear() root.withdraw() try: root.after(0, _hide) except RuntimeError: pass def close(self) -> None: root = self._root if root is None or self._closed: return done = threading.Event() def _shutdown() -> None: try: if self._canvas is not None: self._canvas.delete("all") self._photos.clear() self._icon_src.clear() root.quit() finally: done.set() try: root.after(0, _shutdown) except RuntimeError: done.set() done.wait(timeout=2.0) self._thread.join(timeout=2.0) self._closed = True def _redraw(self) -> None: canvas = self._canvas if canvas is None: return canvas.delete("all") self._photos.clear() sw, sh = self._screen icon_h = max(8, int(round(self.icon_h_rel * sh))) gap = max(0, int(round(self.icon_gap_rel * sh))) y_gap = int(round(self.y_gap_rel * sh)) for slot in self.cfg.get("slots") or []: idx = int(slot["index"]) hero = self._roster.get(idx) if not hero: continue roles = [r for r in ROLE_ORDER if r in set(self.roles_by_key.get(hero, []))] if not roles: continue x, y, w, h = slot_rect_px(slot, self.cfg, sw, sh) icons = [img for r in roles if (img := self._scaled_icon(r, icon_h)) is not None] if not icons: continue total_w = sum(img.width() for img in icons) + gap * (len(icons) - 1) cx = x + w / 2 left = int(round(cx - total_w / 2)) top = y + h + y_gap cursor = left for img in icons: canvas.create_image(cursor, top, image=img, anchor="nw") cursor += img.width() + gap mark = max(12, int(round(self.mark_size_rel * sh))) pad = max(2, int(round(self.mark_pad_rel * sh))) font_size = max(8, int(round(mark * 0.55))) for key, rank in self._marks.items(): cell = self._cells.get(key) if not cell: continue x0 = int(cell["x0"]) y0 = int(cell["y0"]) x1 = x0 + pad y1 = y0 + pad x2 = x1 + mark y2 = y1 + mark canvas.create_rectangle(x1, y1, x2, y2, fill=self.rec_color, outline=self.rec_color) canvas.create_text( (x1 + x2) / 2, (y1 + y2) / 2, text=str(rank), fill=self.rec_text, font=("Segoe UI", font_size, "bold"), )