411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""Transparent click-through overlay: 克/搭/补 marks + analysis bar + item icons.
|
|
|
|
Runs a Tk root on a background thread. DraftSession calls set_grid_marks(),
|
|
set_analysis(), and set_items(); 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 threading
|
|
import tkinter as tk
|
|
|
|
import mss
|
|
|
|
from shared.paths import ITEM_ICONS
|
|
|
|
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"
|
|
DEFAULT_ITEM_CORE_BG = "#1a2332"
|
|
DEFAULT_ITEM_ANSWER_BG = "#2a1f14"
|
|
DEFAULT_ITEM_REASON_FG = "#e8eef7"
|
|
LABEL_ORDER = ("克", "搭", "补")
|
|
LABEL_COLORS = {
|
|
"克": "counter",
|
|
"搭": "synergy",
|
|
"补": "fill",
|
|
}
|
|
|
|
|
|
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.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.items_y_rel = float(o.get("items_y_rel", 0.16))
|
|
self.item_icon_h_rel = float(o.get("item_icon_h_rel", 0.036))
|
|
self.item_gap_rel = float(o.get("item_gap_rel", 0.01))
|
|
self.item_core_bg = str(o.get("item_core_bg", DEFAULT_ITEM_CORE_BG))
|
|
self.item_answer_bg = str(o.get("item_answer_bg", DEFAULT_ITEM_ANSWER_BG))
|
|
self.item_reason_fg = str(o.get("item_reason_fg", DEFAULT_ITEM_REASON_FG))
|
|
self.item_icons_dir = Path(o.get("item_icons_dir") or ITEM_ICONS)
|
|
self._cells: dict[str, dict] = {}
|
|
self._marks: dict[str, list[str]] = {}
|
|
self._analysis = ""
|
|
self._items: list[dict] = []
|
|
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)
|
|
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 set_roster(self, confirmed: dict[int, str]) -> None:
|
|
"""No-op kept for DraftSession compatibility (role tags under avatars removed)."""
|
|
return
|
|
|
|
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_items(self, items: list[dict] | None) -> None:
|
|
"""Item icon bar after self-lock: [{key, name_loc, kind, reason}, ...]."""
|
|
parsed: list[dict] = []
|
|
for row in items or []:
|
|
key = row.get("key")
|
|
if not key:
|
|
continue
|
|
parsed.append({
|
|
"key": str(key),
|
|
"name_loc": str(row.get("name_loc") or key),
|
|
"kind": str(row.get("kind") or "core"),
|
|
"reason": str(row.get("reason") or ""),
|
|
})
|
|
if parsed == self._items:
|
|
return
|
|
self._items = parsed
|
|
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 = ""
|
|
self._items = []
|
|
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 _load_item_src(self, key: str) -> tk.PhotoImage | None:
|
|
assert self._root is not None
|
|
if key in self._icon_src:
|
|
return self._icon_src[key]
|
|
path = self.item_icons_dir / f"{key}.png"
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
img = tk.PhotoImage(master=self._root, file=str(path))
|
|
except tk.TclError:
|
|
return None
|
|
self._icon_src[key] = img
|
|
return img
|
|
|
|
def _scaled_item_icon(self, key: str, target_h: int) -> tk.PhotoImage | None:
|
|
src = self._load_item_src(key)
|
|
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 _draw_items(self, canvas: tk.Canvas, sw: int, sh: int) -> None:
|
|
if not self._items:
|
|
return
|
|
icon_h = max(20, int(round(self.item_icon_h_rel * sh)))
|
|
gap = max(4, int(round(self.item_gap_rel * sw)))
|
|
reason_font = max(9, int(round(0.011 * sh)))
|
|
pad = max(4, int(round(0.003 * sh)))
|
|
top = int(round(self.items_y_rel * sh))
|
|
|
|
cells: list[tuple[dict, tk.PhotoImage | None, int, int]] = []
|
|
for row in self._items:
|
|
img = self._scaled_item_icon(row["key"], icon_h)
|
|
iw = img.width() if img is not None else icon_h
|
|
ih = img.height() if img is not None else icon_h
|
|
cells.append((row, img, iw, ih))
|
|
|
|
cell_w = max((iw for _, _, iw, _ in cells), default=icon_h) + 2 * pad
|
|
# Extra width for short Chinese reason under icon.
|
|
cell_w = max(cell_w, reason_font * 4 + 2 * pad)
|
|
cell_h = max((ih for _, _, _, ih in cells), default=icon_h) + reason_font + 3 * pad
|
|
total_w = len(cells) * cell_w + gap * max(0, len(cells) - 1)
|
|
left = max(0, (sw - total_w) // 2)
|
|
|
|
for i, (row, img, iw, ih) in enumerate(cells):
|
|
x0 = left + i * (cell_w + gap)
|
|
y0 = top
|
|
x1 = x0 + cell_w
|
|
y1 = y0 + cell_h
|
|
bg = self.item_answer_bg if row.get("kind") == "answer" else self.item_core_bg
|
|
canvas.create_rectangle(x0, y0, x1, y1, fill=bg, outline=bg)
|
|
cx = (x0 + x1) / 2
|
|
if img is not None:
|
|
canvas.create_image(cx, y0 + pad + ih / 2, image=img, anchor="center")
|
|
else:
|
|
name = row.get("name_loc") or row["key"]
|
|
canvas.create_text(
|
|
cx,
|
|
y0 + pad + icon_h / 2,
|
|
text=name[:4],
|
|
fill=self.item_reason_fg,
|
|
font=("Microsoft YaHei UI", max(8, reason_font - 1), "bold"),
|
|
)
|
|
reason = (row.get("reason") or "").strip() or ("应对" if row.get("kind") == "answer" else "常用")
|
|
canvas.create_text(
|
|
cx,
|
|
y1 - pad - reason_font / 2,
|
|
text=reason[:6],
|
|
fill=self.item_reason_fg,
|
|
font=("Microsoft YaHei UI", reason_font),
|
|
)
|
|
|
|
def _redraw(self) -> None:
|
|
canvas = self._canvas
|
|
if canvas is None:
|
|
return
|
|
canvas.delete("all")
|
|
self._photos.clear()
|
|
sw, sh = self._screen
|
|
|
|
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"),
|
|
)
|
|
|
|
self._draw_items(canvas, sw, sh)
|
|
|
|
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"),
|
|
)
|