"""Transparent click-through overlay: role tags + 克/搭/补 marks + analysis bar. Runs a Tk root on a background thread. DraftSession calls set_roster(), set_grid_marks(), and set_analysis(); geometry uses relative coords. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import json import threading import tkinter as tk 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_COUNTER_COLOR = "#2ec4b6" DEFAULT_SYNERGY_COLOR = "#e9a825" DEFAULT_FILL_COLOR = "#9b7ebd" DEFAULT_MARK_TEXT = "#0b1220" DEFAULT_ANALYSIS_BG = "#1a2332" DEFAULT_ANALYSIS_FG = "#e8eef7" LABEL_ORDER = ("克", "搭", "补") LABEL_COLORS = { "克": "counter", "搭": "synergy", "补": "fill", } 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.mark_gap_rel = float(o.get("mark_gap_rel", 0.002)) self.counter_color = str(o.get("counter_color", o.get("rec_color", DEFAULT_COUNTER_COLOR))) self.synergy_color = str(o.get("synergy_color", DEFAULT_SYNERGY_COLOR)) self.fill_color = str(o.get("fill_color", DEFAULT_FILL_COLOR)) self.mark_text = str(o.get("mark_text_color", o.get("rec_text_color", DEFAULT_MARK_TEXT))) self.analysis_y_rel = float(o.get("analysis_y_rel", 0.12)) self.analysis_h_rel = float(o.get("analysis_h_rel", 0.028)) self.analysis_font_rel = float(o.get("analysis_font_rel", 0.014)) self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG)) self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG)) self.roles_by_key = _load_roles_by_key() self._roster: dict[int, str] = {} self._cells: dict[str, dict] = {} self._marks: dict[str, list[str]] = {} self._analysis = "" 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 _label_fill(self, label: str) -> str: kind = LABEL_COLORS.get(label) if kind == "synergy": return self.synergy_color if kind == "fill": return self.fill_color return self.counter_color 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_analysis(self, text: str | None) -> None: """Short lineup analysis banner (empty clears).""" value = (text or "").strip() if value == self._analysis: return self._analysis = value self._schedule_redraw() def set_grid_marks( self, cells: dict[str, dict] | None, marks: dict[str, list[str] | int] | list[dict] | None, ) -> None: """克/搭/补 badges on hero-grid cells. marks: key->labels list, key->legacy rank int, or suggest_marks list. """ cells = {str(k): dict(v) for k, v in (cells or {}).items()} parsed: dict[str, list[str]] = {} if isinstance(marks, list): for item in marks: key = item.get("key") if not key: continue labels = item.get("labels") if labels: parsed[str(key)] = [str(x) for x in labels if x in LABEL_ORDER] elif item.get("rank"): parsed[str(key)] = ["克"] elif marks: for key, val in marks.items(): if isinstance(val, (list, tuple)): labs = [str(x) for x in val if x in LABEL_ORDER] elif isinstance(val, int) and val > 0: labs = ["克"] elif isinstance(val, str) and val in LABEL_ORDER: labs = [val] else: labs = [] if labs: parsed[str(key)] = labs 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() self._analysis = "" 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)) if self._analysis: bar_h = max(18, int(round(self.analysis_h_rel * sh))) bar_y = int(round(self.analysis_y_rel * sh)) font_size = max(10, int(round(self.analysis_font_rel * sh))) pad_x = max(12, int(round(0.01 * sw))) # Estimate text width roughly; keep banner centered and readable. approx_w = min(sw - 2 * pad_x, max(200, int(len(self._analysis) * font_size * 0.95) + 2 * pad_x)) x0 = (sw - approx_w) // 2 y0 = bar_y x1 = x0 + approx_w y1 = y0 + bar_h canvas.create_rectangle(x0, y0, x1, y1, fill=self.analysis_bg, outline=self.analysis_bg) canvas.create_text( (x0 + x1) / 2, (y0 + y1) / 2, text=self._analysis, fill=self.analysis_fg, font=("Microsoft YaHei UI", font_size, "bold"), ) 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))) mark_gap = max(1, int(round(self.mark_gap_rel * sh))) font_size = max(8, int(round(mark * 0.55))) for key, labels in self._marks.items(): cell = self._cells.get(key) if not cell: continue ordered = [lab for lab in LABEL_ORDER if lab in labels] if not ordered: continue x0 = int(cell["x0"]) y0 = int(cell["y0"]) x1 = x0 + pad y1 = y0 + pad for i, lab in enumerate(ordered): bx1 = x1 + i * (mark + mark_gap) by1 = y1 bx2 = bx1 + mark by2 = by1 + mark fill = self._label_fill(lab) canvas.create_rectangle(bx1, by1, bx2, by2, fill=fill, outline=fill) canvas.create_text( (bx1 + bx2) / 2, (by1 + by2) / 2, text=lab, fill=self.mark_text, font=("Microsoft YaHei UI", font_size, "bold"), )