v0.6.5: size hero detail drawer from viewport; add PC post-lock item tips.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+18
-6
@@ -102,7 +102,7 @@
|
||||
"min_iou": 0.55
|
||||
},
|
||||
"recommend": {
|
||||
"comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). top_n<=0 = no cap. No AI.",
|
||||
"comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). After self-lock: core+answer items. top_n<=0 = no cap. No AI.",
|
||||
"enabled": true,
|
||||
"top_n": 0,
|
||||
"min_enemies": 1,
|
||||
@@ -115,6 +115,15 @@
|
||||
"3": ["Initiator", "Durable", "Carry"],
|
||||
"4": ["Support"],
|
||||
"5": ["Support"]
|
||||
},
|
||||
"items": {
|
||||
"comment": "After self hero locked: core from hero_items + qualitative answers vs enemies. No fears/STRATZ stats.",
|
||||
"enabled": true,
|
||||
"hero_items_path": "web/data/hero_items.json",
|
||||
"items_meta_path": "web/data/items_meta.json",
|
||||
"core_n": 3,
|
||||
"answer_n": 3,
|
||||
"max_total": 6
|
||||
}
|
||||
},
|
||||
"gsi": {
|
||||
@@ -138,11 +147,8 @@
|
||||
"dump_payloads": true
|
||||
},
|
||||
"overlay": {
|
||||
"comment": "Role tags under top-bar + 克/搭/补 marks + lineup analysis banner.",
|
||||
"comment": "克/搭/补 marks + lineup analysis banner + post-lock item icon bar (no role tags under top-bar).",
|
||||
"enabled": true,
|
||||
"y_gap_rel": 0.008,
|
||||
"icon_h_rel": 0.016,
|
||||
"icon_gap_rel": 0.002,
|
||||
"mark_size_rel": 0.018,
|
||||
"mark_pad_rel": 0.004,
|
||||
"mark_gap_rel": 0.002,
|
||||
@@ -154,7 +160,13 @@
|
||||
"analysis_h_rel": 0.028,
|
||||
"analysis_font_rel": 0.014,
|
||||
"analysis_bg": "#1a2332",
|
||||
"analysis_fg": "#e8eef7"
|
||||
"analysis_fg": "#e8eef7",
|
||||
"items_y_rel": 0.16,
|
||||
"item_icon_h_rel": 0.036,
|
||||
"item_gap_rel": 0.01,
|
||||
"item_core_bg": "#1a2332",
|
||||
"item_answer_bg": "#2a1f14",
|
||||
"item_reason_fg": "#e8eef7"
|
||||
},
|
||||
"calibrated_from": "draft_141704.png"
|
||||
}
|
||||
+88
-15
@@ -43,6 +43,7 @@ from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_fram
|
||||
from shared.grid import bans, hero_table, read_grid
|
||||
from modes import detect_mode, load_mode_templates
|
||||
from recognize import recognize_image
|
||||
from item_suggest import suggest_items
|
||||
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
|
||||
from roles import ROLES, detect_roles, load_role_templates
|
||||
|
||||
@@ -95,10 +96,18 @@ class DraftSession:
|
||||
self.recommend_archetypes = bool(rec.get("archetypes", True))
|
||||
self.recommend_role_tags = rec.get("role_tags")
|
||||
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None
|
||||
items_cfg = rec.get("items") or {}
|
||||
self.recommend_items_enabled = bool(items_cfg.get("enabled", True))
|
||||
self.recommend_items_core_n = int(items_cfg.get("core_n", 3))
|
||||
self.recommend_items_answer_n = int(items_cfg.get("answer_n", 3))
|
||||
self.recommend_items_max_total = int(items_cfg.get("max_total", 6))
|
||||
self.recommend_hero_items_path = items_cfg.get("hero_items_path")
|
||||
self.recommend_items_meta_path = items_cfg.get("items_meta_path")
|
||||
self._rec_warned = False
|
||||
self._last_rec_sig: tuple | None = None
|
||||
self._last_enemy_profile: dict = {}
|
||||
self._last_rec_meta: dict = {}
|
||||
self._last_item_sig: tuple | None = None
|
||||
|
||||
def _push_overlay(self, confirmed: dict[int, str]) -> None:
|
||||
if self.overlay is None:
|
||||
@@ -113,6 +122,7 @@ class DraftSession:
|
||||
cells: dict | None,
|
||||
picks: list[dict],
|
||||
analysis: str = "",
|
||||
items: list[dict] | None = None,
|
||||
) -> None:
|
||||
if self.overlay is None:
|
||||
return
|
||||
@@ -121,6 +131,8 @@ class DraftSession:
|
||||
self.overlay.set_grid_marks(cells or {}, marks)
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis(analysis or "")
|
||||
if hasattr(self.overlay, "set_items"):
|
||||
self.overlay.set_items(items if items is not None else [])
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] rec overlay update failed: {e}")
|
||||
|
||||
@@ -134,9 +146,12 @@ class DraftSession:
|
||||
self.overlay.set_grid_marks({}, {})
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis("")
|
||||
if hasattr(self.overlay, "set_items"):
|
||||
self.overlay.set_items([])
|
||||
self.overlay.show()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] overlay show failed: {e}")
|
||||
self._last_item_sig = None
|
||||
if self.recommend_enabled and not self._rec_warned:
|
||||
rel = self.relations or {}
|
||||
if not rel.get("counters") and not rel.get("synergies"):
|
||||
@@ -157,6 +172,7 @@ class DraftSession:
|
||||
"cells": None,
|
||||
"mode": None,
|
||||
"recommendations": [],
|
||||
"item_recommendations": [],
|
||||
}
|
||||
polls = 0
|
||||
last_frame = None
|
||||
@@ -248,8 +264,8 @@ class DraftSession:
|
||||
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
|
||||
timeline.append(event)
|
||||
self._log_event(event, info)
|
||||
if state == HERO_SELECTION:
|
||||
self._refresh_recommendations(confirmed, info, gsi_fn)
|
||||
# Hero marks in selection; item bar after self-lock (incl. strategy tail).
|
||||
self._refresh_recommendations(confirmed, info, gsi_fn)
|
||||
|
||||
n = len(confirmed)
|
||||
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
|
||||
@@ -276,6 +292,8 @@ class DraftSession:
|
||||
self.overlay.set_grid_marks({}, {})
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis("")
|
||||
if hasattr(self.overlay, "set_items"):
|
||||
self.overlay.set_items([])
|
||||
self.overlay.hide()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] overlay hide failed: {e}")
|
||||
@@ -383,26 +401,70 @@ class DraftSession:
|
||||
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
|
||||
f"(contrast margin {res['margin']}) - {names}")
|
||||
|
||||
def _refresh_item_recommendations(
|
||||
self,
|
||||
confirmed: dict,
|
||||
info: dict,
|
||||
*,
|
||||
self_hero: str,
|
||||
enemies: list[str],
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""After self-lock: clear hero marks and show core + answer items."""
|
||||
analysis = (self._last_rec_meta or {}).get("analysis") or ""
|
||||
if not self.recommend_items_enabled:
|
||||
if info.get("recommendations") or info.get("item_recommendations") or self._last_rec_sig != ("locked",):
|
||||
info["recommendations"] = []
|
||||
info["item_recommendations"] = []
|
||||
self._last_rec_sig = ("locked",)
|
||||
self._last_item_sig = ("locked-off",)
|
||||
self._push_rec_overlay(info.get("cells"), [], analysis, [])
|
||||
return
|
||||
sig = (self_hero, tuple(enemies))
|
||||
if not force and sig == self._last_item_sig:
|
||||
return
|
||||
self._last_item_sig = sig
|
||||
self._last_rec_sig = ("locked",)
|
||||
info["recommendations"] = []
|
||||
items = suggest_items(
|
||||
self_hero,
|
||||
enemies,
|
||||
core_n=self.recommend_items_core_n,
|
||||
answer_n=self.recommend_items_answer_n,
|
||||
max_total=self.recommend_items_max_total,
|
||||
hero_items_path=self.recommend_hero_items_path,
|
||||
items_meta_path=self.recommend_items_meta_path,
|
||||
)
|
||||
info["item_recommendations"] = items
|
||||
self._push_rec_overlay(info.get("cells"), [], analysis, items)
|
||||
if items:
|
||||
bits = [
|
||||
f"{it.get('name_loc') or it['key']}({it.get('reason') or it.get('kind')})"
|
||||
for it in items
|
||||
]
|
||||
self.log(
|
||||
f"[rec:items] {self.hero_names.get(self_hero, self_hero)} vs "
|
||||
f"{[self.hero_names.get(e, e) for e in enemies]}: {', '.join(bits)}"
|
||||
)
|
||||
|
||||
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
|
||||
if not self.recommend_enabled:
|
||||
return
|
||||
if not self.relations and not self.recommend_archetypes:
|
||||
return
|
||||
gsi = (gsi_fn() or {}) if gsi_fn else {}
|
||||
self_slot = gsi_slot(gsi) or info.get("self_slot")
|
||||
self_team = gsi.get("team") or info.get("self_team")
|
||||
role = info["roles"].get(self_slot) if self_slot else None
|
||||
position = role["position"] if role else None
|
||||
# Stop suggesting once you have locked a hero.
|
||||
if self_slot and confirmed.get(self_slot):
|
||||
if info.get("recommendations") or self._last_rec_sig is not None:
|
||||
info["recommendations"] = []
|
||||
self._last_enemy_profile = {}
|
||||
self._last_rec_meta = {}
|
||||
self._last_rec_sig = ("locked",)
|
||||
self._push_rec_overlay(info.get("cells"), [], "")
|
||||
return
|
||||
self_hero = (confirmed.get(self_slot) if self_slot else None) or gsi.get("hero")
|
||||
enemies = enemy_keys(confirmed, self_team)
|
||||
# Once locked, switch from hero marks to item icon bar.
|
||||
if self_slot and self_hero:
|
||||
self._refresh_item_recommendations(
|
||||
confirmed, info, self_hero=self_hero, enemies=enemies, force=force,
|
||||
)
|
||||
return
|
||||
if not self.relations and not self.recommend_archetypes:
|
||||
return
|
||||
allies = ally_keys(confirmed, self_team, self_slot)
|
||||
exclude = set(confirmed.values())
|
||||
if info.get("unavailable"):
|
||||
@@ -411,11 +473,13 @@ class DraftSession:
|
||||
if not force and sig == self._last_rec_sig:
|
||||
return
|
||||
self._last_rec_sig = sig
|
||||
self._last_item_sig = None
|
||||
if len(enemies) < self.recommend_min_enemies:
|
||||
info["recommendations"] = []
|
||||
info["item_recommendations"] = []
|
||||
self._last_enemy_profile = {}
|
||||
self._last_rec_meta = {}
|
||||
self._push_rec_overlay(info.get("cells"), [], "")
|
||||
self._push_rec_overlay(info.get("cells"), [], "", [])
|
||||
return
|
||||
result = suggest_marks(
|
||||
position=position,
|
||||
@@ -433,6 +497,7 @@ class DraftSession:
|
||||
profile = result.get("enemy_profile") or {}
|
||||
analysis = result.get("analysis") or ""
|
||||
info["recommendations"] = picks
|
||||
info["item_recommendations"] = []
|
||||
self._last_enemy_profile = profile
|
||||
self._last_rec_meta = {
|
||||
"analysis": analysis,
|
||||
@@ -441,7 +506,7 @@ class DraftSession:
|
||||
"ally_gaps": list(result.get("ally_gaps") or []),
|
||||
"ally_profile": dict(result.get("ally_profile") or {}),
|
||||
}
|
||||
self._push_rec_overlay(info.get("cells"), picks, analysis)
|
||||
self._push_rec_overlay(info.get("cells"), picks, analysis, [])
|
||||
if picks or analysis:
|
||||
bits = []
|
||||
for p in picks[:12]:
|
||||
@@ -597,6 +662,7 @@ class DraftSession:
|
||||
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
||||
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
|
||||
"picks": info.get("recommendations") or [],
|
||||
"items": info.get("item_recommendations") or [],
|
||||
},
|
||||
}
|
||||
if info["unavailable"] is not None:
|
||||
@@ -680,6 +746,13 @@ def describe(summary: dict) -> list[str]:
|
||||
name = p.get("name_loc") or loc(p["key"], names)
|
||||
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
||||
lines.append(f"rec : {len(picks)} — {', '.join(bits)}")
|
||||
items = rec.get("items") or []
|
||||
if items:
|
||||
bits = [
|
||||
f"{it.get('name_loc') or it.get('key')}({it.get('reason') or it.get('kind')})"
|
||||
for it in items
|
||||
]
|
||||
lines.append(f"items : {', '.join(bits)}")
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ class Watcher:
|
||||
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
|
||||
try:
|
||||
self.overlay = DraftOverlay(cfg)
|
||||
print("[draft] role-tag overlay ready", flush=True)
|
||||
print("[draft] overlay ready", flush=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[draft] overlay disabled: {e}", flush=True)
|
||||
self.overlay = None
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Draft item suggestions: hero core builds + qualitative answers vs enemies.
|
||||
|
||||
Core items come from web/data/hero_items.json (relative popularity).
|
||||
Answer items are rule-mapped from enemy tags / push-global archetypes.
|
||||
Does not read hero_item_fears stats or STRATZ.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
|
||||
from draft_archetypes import detect_archetypes, tag_profile
|
||||
from shared.grid import hero_table
|
||||
from shared.hero_tags import tags_for_hero
|
||||
from shared.paths import DATA, ROOT
|
||||
|
||||
DEFAULT_HERO_ITEMS = DATA / "hero_items.json"
|
||||
DEFAULT_ITEMS_META = DATA / "items_meta.json"
|
||||
|
||||
# Heroes that commonly pick from fog / invis — qualitative only.
|
||||
INVIS_HEROES = frozenset({
|
||||
"riki",
|
||||
"bounty_hunter",
|
||||
"clinkz",
|
||||
"weaver",
|
||||
"nyx_assassin",
|
||||
"templar_assassin",
|
||||
"mirana",
|
||||
"treant",
|
||||
"windrunner",
|
||||
"slark",
|
||||
"invoker",
|
||||
"sand_king",
|
||||
})
|
||||
|
||||
# Soft heal / sustain cores — vessel answers.
|
||||
HEAL_HEROES = frozenset({
|
||||
"omniknight",
|
||||
"winter_wyvern",
|
||||
"bane",
|
||||
"undying",
|
||||
"abaddon",
|
||||
"oracle",
|
||||
"chen",
|
||||
"io",
|
||||
"wisp",
|
||||
"dazzle",
|
||||
"warlock",
|
||||
})
|
||||
|
||||
# Fallback Chinese names when catalogs miss an entry.
|
||||
_FALLBACK_NAMES = {
|
||||
"black_king_bar": "黑皇杖",
|
||||
"bfury": "狂战斧",
|
||||
"disperser": "散魂剑",
|
||||
"gem": "真视宝石",
|
||||
"dust": "显影之尘",
|
||||
"pipe": "洞察烟斗",
|
||||
"eternal_shroud": "永世护盾",
|
||||
"travel_boots": "远行鞋",
|
||||
"crimson_guard": "赤红甲",
|
||||
"spirit_vessel": "魂之灵瓮",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_path(path: str | Path | None, default: Path) -> Path:
|
||||
if not path:
|
||||
return default
|
||||
p = Path(path)
|
||||
if not p.is_absolute():
|
||||
p = ROOT / p
|
||||
return p
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_hero_items(path_str: str) -> dict:
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_items_meta(path_str: str) -> dict[str, str]:
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
if not isinstance(items, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for row in items.values():
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
key = row.get("key")
|
||||
if not key:
|
||||
continue
|
||||
name = row.get("name_loc") or row.get("dname") or key
|
||||
out[str(key)] = str(name)
|
||||
return out
|
||||
|
||||
|
||||
def _tags_by_key() -> dict[str, list[str]]:
|
||||
return {
|
||||
h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles"))
|
||||
for h in hero_table()
|
||||
}
|
||||
|
||||
|
||||
def _name_for(
|
||||
key: str,
|
||||
*,
|
||||
catalog: dict[str, dict],
|
||||
meta_names: dict[str, str],
|
||||
) -> str:
|
||||
row = catalog.get(key) or {}
|
||||
return (
|
||||
str(row.get("name_loc") or "")
|
||||
or meta_names.get(key)
|
||||
or _FALLBACK_NAMES.get(key)
|
||||
or key
|
||||
)
|
||||
|
||||
|
||||
def _catalog_from_hero_items(data: dict) -> dict[str, dict]:
|
||||
"""id-str -> row and also key -> row for lookups."""
|
||||
items = data.get("items") or {}
|
||||
by_key: dict[str, dict] = {}
|
||||
for row in items.values():
|
||||
if isinstance(row, dict) and row.get("key"):
|
||||
by_key[str(row["key"])] = row
|
||||
return by_key
|
||||
|
||||
|
||||
def core_items_for_hero(
|
||||
self_hero: str,
|
||||
data: dict,
|
||||
*,
|
||||
core_n: int = 3,
|
||||
catalog: dict[str, dict] | None = None,
|
||||
meta_names: dict[str, str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Top relative-popularity finished items for one hero."""
|
||||
if not self_hero or core_n <= 0:
|
||||
return []
|
||||
by_hero = data.get("by_hero") or {}
|
||||
rows = list(by_hero.get(self_hero) or [])
|
||||
items_by_id = data.get("items") or {}
|
||||
catalog = catalog if catalog is not None else _catalog_from_hero_items(data)
|
||||
meta_names = meta_names or {}
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
if len(out) >= core_n:
|
||||
break
|
||||
iid = str(row.get("id") if isinstance(row, dict) else row)
|
||||
meta = items_by_id.get(iid) or items_by_id.get(int(iid) if iid.isdigit() else iid) or {}
|
||||
key = str(meta.get("key") or "")
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({
|
||||
"key": key,
|
||||
"name_loc": _name_for(key, catalog=catalog, meta_names=meta_names),
|
||||
"kind": "core",
|
||||
"reason": "常用",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def answer_items_for_enemies(
|
||||
enemies: list[str],
|
||||
*,
|
||||
answer_n: int = 3,
|
||||
catalog: dict[str, dict] | None = None,
|
||||
meta_names: dict[str, str] | None = None,
|
||||
tags_by_key: dict[str, list[str]] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Qualitative counter items from enemy tags / archetypes."""
|
||||
if answer_n <= 0 or not enemies:
|
||||
return []
|
||||
tags_by_key = tags_by_key or _tags_by_key()
|
||||
catalog = catalog or {}
|
||||
meta_names = meta_names or {}
|
||||
profile = tag_profile(enemies, tags_by_key)
|
||||
arches = detect_archetypes(enemies, tags_by_key)
|
||||
|
||||
candidates: list[tuple[str, str]] = []
|
||||
|
||||
control_n = int(profile.get("控制") or 0) + int(profile.get("先手") or 0)
|
||||
if control_n >= 2:
|
||||
candidates.append(("black_king_bar", "克控制"))
|
||||
|
||||
if int(profile.get("幻象") or 0) >= 1 or any(
|
||||
"幻象" in (tags_by_key.get(e) or []) for e in enemies
|
||||
):
|
||||
candidates.append(("bfury", "清幻象"))
|
||||
candidates.append(("disperser", "打幻象"))
|
||||
|
||||
if any(e in INVIS_HEROES for e in enemies):
|
||||
candidates.append(("gem", "克隐身"))
|
||||
candidates.append(("dust", "显影"))
|
||||
|
||||
if int(profile.get("爆发") or 0) >= 2:
|
||||
candidates.append(("pipe", "克魔法"))
|
||||
candidates.append(("eternal_shroud", "魔抗"))
|
||||
|
||||
if any(e in HEAL_HEROES for e in enemies):
|
||||
candidates.append(("spirit_vessel", "克回复"))
|
||||
|
||||
if "push" in arches:
|
||||
candidates.append(("crimson_guard", "抗推进"))
|
||||
candidates.append(("travel_boots", "对推进"))
|
||||
if "global" in arches:
|
||||
candidates.append(("travel_boots", "对全球流"))
|
||||
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for key, reason in candidates:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({
|
||||
"key": key,
|
||||
"name_loc": _name_for(key, catalog=catalog, meta_names=meta_names),
|
||||
"kind": "answer",
|
||||
"reason": reason,
|
||||
})
|
||||
if len(out) >= answer_n:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def suggest_items(
|
||||
self_hero: str | None,
|
||||
enemies: list[str] | None,
|
||||
*,
|
||||
core_n: int = 3,
|
||||
answer_n: int = 3,
|
||||
max_total: int = 6,
|
||||
hero_items_path: str | Path | None = None,
|
||||
items_meta_path: str | Path | None = None,
|
||||
) -> list[dict]:
|
||||
"""Merge answer items then core items; dedupe by key; soft-fail to []."""
|
||||
if not self_hero or max_total <= 0:
|
||||
return []
|
||||
hi_path = _resolve_path(hero_items_path, DEFAULT_HERO_ITEMS)
|
||||
meta_path = _resolve_path(items_meta_path, DEFAULT_ITEMS_META)
|
||||
data = _load_hero_items(str(hi_path))
|
||||
if not data:
|
||||
return []
|
||||
catalog = _catalog_from_hero_items(data)
|
||||
meta_names = _load_items_meta(str(meta_path))
|
||||
answers = answer_items_for_enemies(
|
||||
list(enemies or []),
|
||||
answer_n=answer_n,
|
||||
catalog=catalog,
|
||||
meta_names=meta_names,
|
||||
)
|
||||
cores = core_items_for_hero(
|
||||
self_hero,
|
||||
data,
|
||||
core_n=core_n,
|
||||
catalog=catalog,
|
||||
meta_names=meta_names,
|
||||
)
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for row in answers + cores:
|
||||
key = row.get("key")
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
if len(out) >= max_total:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HERO_ITEMS",
|
||||
"answer_items_for_enemies",
|
||||
"core_items_for_hero",
|
||||
"suggest_items",
|
||||
]
|
||||
+116
-85
@@ -1,7 +1,7 @@
|
||||
"""Transparent click-through overlay: role tags + 克/搭/补 marks + analysis bar.
|
||||
"""Transparent click-through overlay: 克/搭/补 marks + analysis bar + item icons.
|
||||
|
||||
Runs a Tk root on a background thread. DraftSession calls set_roster(),
|
||||
set_grid_marks(), and set_analysis(); geometry uses relative coords.
|
||||
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
|
||||
@@ -11,26 +11,12 @@ 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",
|
||||
]
|
||||
from shared.paths import ITEM_ICONS
|
||||
|
||||
CHROMA = "#ff00ff"
|
||||
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
||||
@@ -39,6 +25,9 @@ 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",
|
||||
@@ -47,13 +36,6 @@ LABEL_COLORS = {
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
@@ -83,9 +65,6 @@ class DraftOverlay:
|
||||
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))
|
||||
@@ -98,11 +77,17 @@ class DraftOverlay:
|
||||
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.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
|
||||
@@ -137,7 +122,6 @@ class DraftOverlay:
|
||||
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())
|
||||
@@ -157,38 +141,9 @@ class DraftOverlay:
|
||||
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()
|
||||
"""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)."""
|
||||
@@ -198,6 +153,24 @@ class DraftOverlay:
|
||||
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,
|
||||
@@ -265,6 +238,7 @@ class DraftOverlay:
|
||||
self._canvas.delete("all")
|
||||
self._photos.clear()
|
||||
self._analysis = ""
|
||||
self._items = []
|
||||
root.withdraw()
|
||||
|
||||
try:
|
||||
@@ -296,6 +270,85 @@ class DraftOverlay:
|
||||
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:
|
||||
@@ -303,9 +356,6 @@ class DraftOverlay:
|
||||
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)))
|
||||
@@ -327,26 +377,7 @@ class DraftOverlay:
|
||||
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
|
||||
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)))
|
||||
|
||||
Reference in New Issue
Block a user