v0.2.0: relations preview, item shop, abilities, overlay recommend, GSI enhancements
- Add relations/item/abilities preview (serve_relations.py + web/relations/) - Add fetch scripts: hero_items, item_shop, items_meta, hero_abilities, ability_videos, patches, stratz, matchups, portraits - Add overlay.py (role tags + Top-3 cyan marks), recommend.py - Add http_utils.py, loc_format.py, hero_tags.py, item_fears.py - GSI: full payload JSONL dump, foreground window detection - Drop real template library; CDN-only matching - Update docs: CHANGELOG 0.2.0, DESIGN config table, AGENTS module table - .gitignore: exclude large regenerable assets (icons/portraits/videos)
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
"""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"),
|
||||
)
|
||||
Reference in New Issue
Block a user