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:
voson
2026-07-30 17:08:32 +08:00
co-authored by Cursor
parent fefd8c7823
commit 22a9cf256d
16 changed files with 603 additions and 128 deletions
+6 -5
View File
@@ -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;含现役战队席位与昔日国服明星;外号:查理斯→ChaliceSomnus 即 MaybeCN/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`
+4 -3
View File
@@ -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 |
---
+14
View File
@@ -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
+2 -1
View File
@@ -233,7 +233,8 @@ components:
## Layout
桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail`
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度(约 `min(640px, …)`),
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度:按视口预留顶栏 + 约 24% 英雄网格 +
定位标签后取余,再 `clamp` 到约 `320640px`CSS 回退 `min(640px, calc(100dvh - 17.5rem))`),
Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。
技能演示片源为官网 16:9;有横向空间时加宽(上限约 720px / 列宽 58%),
`aspect-ratio: 16/9` + `object-fit: contain`,勿用拉满高度的 `cover` 裁左右。
+18 -6
View File
@@ -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"
}
+87 -14
View File
@@ -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,7 +264,7 @@ class DraftSession:
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
timeline.append(event)
self._log_event(event, info)
if state == HERO_SELECTION:
# Hero marks in selection; item bar after self-lock (incl. strategy tail).
self._refresh_recommendations(confirmed, info, gsi_fn)
n = len(confirmed)
@@ -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
View File
@@ -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
+298
View File
@@ -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",
]
+115 -84
View File
@@ -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:
"""No-op kept for DraftSession compatibility (role tags under avatars removed)."""
return
self._roster = dict(roster)
self._schedule_redraw()
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)))
+2
View File
@@ -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)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+43 -2
View File
@@ -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) => {
+1 -1
View File
@@ -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 = "";
+5 -5
View File
@@ -43,8 +43,8 @@
}
</script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.6.4" />
<script src="/mobile-gate.js?v=0.6.4"></script>
<link rel="stylesheet" href="/style.css?v=0.6.5" />
<script src="/mobile-gate.js?v=0.6.5"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
@@ -233,8 +233,8 @@
</div>
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
<script src="/config.js?v=0.6.4"></script>
<script src="/router.js?v=0.6.4"></script>
<script src="/app.js?v=0.6.4"></script>
<script src="/config.js?v=0.6.5"></script>
<script src="/router.js?v=0.6.5"></script>
<script src="/app.js?v=0.6.5"></script>
</body>
</html>
+3 -2
View File
@@ -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;