From 22a9cf256d7e550ed6282540c4d0e1ae002c6a96 Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 30 Jul 2026 17:08:32 +0800 Subject: [PATCH] v0.6.5: size hero detail drawer from viewport; add PC post-lock item tips. Co-authored-by: Cursor --- AGENTS.md | 11 +- ARCHITECTURE.md | 7 +- CHANGELOG.md | 14 ++ DESIGN.md | 3 +- pc/config.json | 24 ++- pc/draft_session.py | 103 ++++++++++-- pc/gsi_watch.py | 2 +- pc/item_suggest.py | 298 +++++++++++++++++++++++++++++++++++ pc/overlay.py | 201 +++++++++++++---------- shared/grid.py | 2 + shared/paths.py | 2 +- web/export_relations_site.py | 2 +- web/frontend/app.js | 45 +++++- web/frontend/config.js | 2 +- web/frontend/index.html | 10 +- web/frontend/style.css | 5 +- 16 files changed, 603 insertions(+), 128 deletions(-) create mode 100644 pc/item_suggest.py diff --git a/AGENTS.md b/AGENTS.md index 9c81b00..3a555cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,11 @@ climperor/ | `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装 | | `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) | | `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 | +| `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) | | `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) | | `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 | | `pc/capture.py` | 屏幕捕获 | -| `pc/overlay.py` | 顶栏角色标签 + 网格「克/搭/补」方标 + 阵容分析横条(点击穿透) | +| `pc/overlay.py` | 网格「克/搭/补」方标 + 阵容分析横条 + 锁定后装备图标条(点击穿透;不再在顶栏头像下画定位) | | `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 | | `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py`) | | `pc/config.json` | 相对坐标、阈值、GSI、recommend 参数(`relations_path` 指向 `shared/data/relations.json`) | @@ -104,7 +105,7 @@ climperor/ | `web/item_fears.py` | 规则推导「英雄怕的装备」→ `web/data/hero_item_fears.json`;可选读取 `item_counter_stats.json`,对全部英雄的已有机制候选小幅调序;统计发现的新组合须经机制复核后写入 overrides | | `web/data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,Web 版本页只读) | | `web/data/hero_grid_order.json` | Web 站点四列网格顺序 | -| `web/data/hero_items.json` | 核心成品装备缓存(Web 站点只读;由 `fetch_hero_items.py` 生成) | +| `web/data/hero_items.json` | 核心成品装备缓存(Web + PC 锁定后核心装推荐只读;由 `fetch_hero_items.py` 生成) | | `web/data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`(OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) | | `web/data/hero_matches.json` | 同英雄近期比赛列表 + 终局出装/加点 + 可选购买时间(OpenDota;`--source league\|public\|both`;Web「近期比赛」Tab;由 `fetch_hero_matches.py` 生成;**不**参与局内推荐) | | `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→Chalice,Somnus 即 Maybe;CN/EU/SA 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend | @@ -156,7 +157,7 @@ Dota 2 GSI → pc/gsi_watch.py (:3223) → recognize_image (CDN 模板 + 可选天梯遮罩) → roles(分路字)/ shared.grid(禁用 + 单元格)/ modes → recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI) - → overlay 角色标签 + 网格克/搭/补 + 阵容分析条(可选) + → overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选) → pc/results/draft_*.json + 终端时间线 ``` @@ -165,7 +166,7 @@ Dota 2 GSI → pc/gsi_watch.py (:3223) - **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。 - **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。 - **克制 / 搭档数据**:机制克制/搭档只用定性边存 `shared/data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `shared/data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `web/data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board` 存 `web/data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `web/data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `pc/recommend.py` 读取。同英雄近期比赛出装/加点单独存 `web/data/hero_matches.json`,**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `web/data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `web/data/streamers.json`(手工名单 + 抖音 profile 补全;平台含抖音 / B 站 / 斗鱼),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。 -- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `web/data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。 +- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `web/data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。Web「怕」行只读 `hero_item_fears.json`;**禁止**把 fears / counter_stats 统计胜率写入 `pc/recommend.py` 或 `pc/item_suggest.py` 评分。PC 局内出装仅允许:`pc/item_suggest.py` 在本人锁定后只读 `hero_items.json`(核心相对热度)+ 定性敌方 tags/画像规则表 + `items_meta` 名称 + 本地 `item_icons`;仍禁止 STRATZ / hero_stats / matches。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;机制 tags **禁止**写入 relations。 - **机制查询页**:顶层 `/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。 - **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。 - **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。 @@ -223,7 +224,7 @@ python pc/evaluate.py - 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。 - 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。 - 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js`(`parseHash` / `serializeHash`)与 `app.js` 的 `applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。History 深度链接依赖 `web/frontend/_redirects`(Cloudflare)与 `web/serve_relations.py` 的 SPA fallback;预渲染路径变更时同步 `web/seo_prerender.py` 与导出文件列表(含 `router.js` / `_redirects` / `robots.txt`)。 -- 改 `shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`roles` 供 `pc/overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。 +- 改 `shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` / `item_suggest` 缺口与应对装共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。 - 发版上分帝 Web 时同步 bump `web/export_relations_site.py` 的 `SITE_VERSION`、`web/frontend/config.js` 的同名变量,以及 `index.html` 里 `style.css`/`mobile-gate.js`/`config.js`/`router.js`/`app.js` 的 `?v=` 缓存戳;并写 `CHANGELOG.md`。`SITE_VERSION` 语义:末位 = 增量 UI/修复;中段 = 壳层 / 路由 / 可索引或其它阶段性能力成型(如 `0.5.x` → `0.6.0`);数据-only 刷新不 bump。 - 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 54712fa..502b23f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -160,7 +160,7 @@ GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_ove climperor/ ├── pc/ # 局内选将识别(GSI + 截屏 + OpenCV) │ ├── common.py / recognize.py / draft_session.py / gsi_watch.py -│ ├── recommend.py / draft_archetypes.py / roles.py / modes.py / overlay.py +│ ├── recommend.py / item_suggest.py / draft_archetypes.py / roles.py / modes.py / overlay.py │ ├── config.json / templates/ / assets/role_icons/ / samples/ │ └── requirements.txt ├── web/ # 上分帝 Web(前端、数据流水线、部署) @@ -200,11 +200,12 @@ climperor/ | `recommend.archetypes` | 推进/全球流/缺口规则画像 | true | | `recommend.relations_path` | 定性关系文件 | `shared/data/relations.json` | | `recommend.role_tags` | 分路→角色标签过滤(1–5 号位;非定位局不过滤) | 见 config.json | -| `overlay.enabled` | 选将顶栏角色标签悬浮层 | true | -| `overlay.y_gap_rel` / `icon_h_rel` / `icon_gap_rel` | 标签相对屏幕高的间距与尺寸 | 0.008 / 0.016 / 0.002 | +| `recommend.items.*` | 锁定后核心装+应对装(`hero_items` + 定性规则) | 见 config.json | +| `overlay.enabled` | 选将网格「克/搭/补」+ 分析条 + 装备图标条 | true | | `overlay.mark_size_rel` / `mark_pad_rel` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 | | `overlay.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` | | `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json | +| `overlay.items_*` / `item_*` | 锁定后装备图标条位置/尺寸/底色 | 见 config.json | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index a3faea5..0672a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ ## [Unreleased] +### Added + +- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。 + +### Removed + +- PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。 + +## [0.6.5] - 2026-07-30 + +### Changed + +- 英雄详情抽屉按视口高度计算:预留顶栏、约 24% 英雄网格与定位标签,上限 640px(原 `88dvh`/720 几乎遮满网格)。 + ## [0.6.4] - 2026-07-30 ### Added diff --git a/DESIGN.md b/DESIGN.md index a60c3ff..2c55e13 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -233,7 +233,8 @@ components: ## Layout 桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail` -之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度(约 `min(640px, …)`), +之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度:按视口预留顶栏 + 约 24% 英雄网格 + +定位标签后取余,再 `clamp` 到约 `320–640px`(CSS 回退 `min(640px, calc(100dvh - 17.5rem))`), Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。 技能演示片源为官网 16:9;有横向空间时加宽(上限约 720px / 列宽 58%), `aspect-ratio: 16/9` + `object-fit: contain`,勿用拉满高度的 `cover` 裁左右。 diff --git a/pc/config.json b/pc/config.json index eeb2ae9..bfaeab9 100644 --- a/pc/config.json +++ b/pc/config.json @@ -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" } \ No newline at end of file diff --git a/pc/draft_session.py b/pc/draft_session.py index f67a7d1..4f1b9b0 100644 --- a/pc/draft_session.py +++ b/pc/draft_session.py @@ -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 diff --git a/pc/gsi_watch.py b/pc/gsi_watch.py index fa55240..467fbaf 100644 --- a/pc/gsi_watch.py +++ b/pc/gsi_watch.py @@ -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 diff --git a/pc/item_suggest.py b/pc/item_suggest.py new file mode 100644 index 0000000..6c718dd --- /dev/null +++ b/pc/item_suggest.py @@ -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", +] diff --git a/pc/overlay.py b/pc/overlay.py index ceed0d0..1a02671 100644 --- a/pc/overlay.py +++ b/pc/overlay.py @@ -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))) diff --git a/shared/grid.py b/shared/grid.py index 74648c9..8d4ff1f 100644 --- a/shared/grid.py +++ b/shared/grid.py @@ -111,6 +111,8 @@ def block_of_column(cols: list[tuple[int, int]]) -> list[int]: Blocks are separated by a visibly wider gutter than the gap between two cards in the same block. """ + import numpy as np + gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)] if not gaps: return [0] * len(cols) diff --git a/shared/paths.py b/shared/paths.py index e0e220c..52f4be8 100644 --- a/shared/paths.py +++ b/shared/paths.py @@ -36,7 +36,7 @@ HERO_PORTRAITS = WEB_ASSETS / "hero_portraits" ATTR_ICONS = WEB_ASSETS / "attr_icons" # Valve hero-selection role filter icons, chroma keyed to transparent PNGs. ROLE_ICONS = WEB_ASSETS / "role_icons" -# Item icons from Steam CDN (dota_react/items/{key}.png); Climperor web site only. +# Item icons from Steam CDN (dota_react/items/{key}.png); Web + PC draft overlay. ITEM_ICONS = WEB_ASSETS / "item_icons" # Shop category header icons from dota2.com.cn/items/images/itemcat_*.png. ITEM_CAT_ICONS = WEB_ASSETS / "item_cat_icons" diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 46e1961..920f32a 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -56,7 +56,7 @@ from shared.paths import ( from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.6.4" +SITE_VERSION = "0.6.5" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" diff --git a/web/frontend/app.js b/web/frontend/app.js index c5219e0..e6e14aa 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -445,10 +445,40 @@ let detailDrawerDrag = null; let detailDrawerSuppressClickUntil = 0; let detailDrawerClosing = false; +/** Cap / floor for the in-flow hero detail drawer (px). */ +const DETAIL_DRAWER_MAX_H = 640; +const DETAIL_DRAWER_MIN_H = 320; + +/** + * Height that leaves topbar + a usable hero-grid strip + role tags visible. + * Prefer ~24% of the viewport for the grid; never steal the whole screen. + */ +function detailDrawerTargetHeight() { + const vh = window.innerHeight || 800; + const topbar = document.querySelector(".topbar")?.offsetHeight || 60; + const toolbar = document.querySelector(".hero-role-toolbar")?.offsetHeight || 52; + const chrome = topbar + toolbar; + const heroPrefer = Math.round(vh * 0.24); + const heroReserve = Math.min( + Math.max(140, heroPrefer), + Math.max(120, vh - chrome - DETAIL_DRAWER_MIN_H) + ); + const available = vh - chrome - heroReserve; + return Math.max(DETAIL_DRAWER_MIN_H, Math.min(DETAIL_DRAWER_MAX_H, available)); +} + +function applyDetailDrawerHeight() { + const drawer = $("#detail-drawer"); + const h = detailDrawerTargetHeight(); + if (drawer) drawer.style.setProperty("--detail-fixed-h", `${h}px`); + return h; +} + function detailDrawerFullHeight() { + const h = applyDetailDrawerHeight(); const panel = $("#detail"); if (panel && panel.offsetHeight) return panel.offsetHeight; - return Math.min(720, Math.round((window.innerHeight || 800) * 0.88)); + return h; } function syncDetailDrawer() { @@ -463,6 +493,7 @@ function syncDetailDrawer() { resetDetailDrawerDrag(); return; } + applyDetailDrawerHeight(); drawer.classList.remove("is-collapsed"); drawer.style.maxHeight = ""; } @@ -6090,7 +6121,17 @@ async function main() { if (t.closest && t.closest(".skill-icon.talent-trigger")) return; closeTalentPopover(); }); - window.addEventListener("resize", closeTalentPopover); + window.addEventListener("resize", () => { + closeTalentPopover(); + if ( + state.page === "heroes" && + state.selectedKey && + !detailDrawerClosing && + !detailDrawerDrag + ) { + applyDetailDrawerHeight(); + } + }); document.addEventListener( "scroll", (e) => { diff --git a/web/frontend/config.js b/web/frontend/config.js index 7b22bf9..8405f1e 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -1,5 +1,5 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.6.4"; +var SITE_VERSION = "0.6.5"; var SITE_ORIGIN = ""; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/frontend/index.html b/web/frontend/index.html index b3d27e6..64ac92d 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -43,8 +43,8 @@ } - - + +

DOTA2 上分帝

@@ -233,8 +233,8 @@
- - - + + + diff --git a/web/frontend/style.css b/web/frontend/style.css index 339dbfb..edd4f7b 100644 --- a/web/frontend/style.css +++ b/web/frontend/style.css @@ -477,9 +477,10 @@ body.detail-drawer-open .hero-role-toolbar { overflow: auto; } -/* In-flow bottom drawer: height animates so the tagbar above is pushed up. */ +/* In-flow bottom drawer: height animates so the tagbar above is pushed up. + Fallback reserves ~topbar + hero strip + role tags; JS may override --detail-fixed-h in px. */ .detail-drawer { - --detail-fixed-h: min(720px, 88dvh); + --detail-fixed-h: min(640px, calc(100dvh - 17.5rem)); flex: 0 0 auto; position: relative; z-index: 2;