Files
climperor/overlay.py
T
vosonandCursor 3ec8007077 v0.5.59: item counter evidence for fears, catch up Web features to site version.
Ship OpenDota counter-stats reordering for feared items, finalize SITE_VERSION/docs for rankings/streamers/trends/matches/mechanics and draft archetypes, and ignore regenerable Web data caches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 02:11:49 +08:00

376 lines
13 KiB
Python

"""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 json
import sys
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"),
)