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:
@@ -36,10 +36,11 @@ climperor/
|
|||||||
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装 |
|
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装 |
|
||||||
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) |
|
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) |
|
||||||
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
|
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
|
||||||
|
| `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) |
|
||||||
| `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) |
|
| `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) |
|
||||||
| `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 |
|
| `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 |
|
||||||
| `pc/capture.py` | 屏幕捕获 |
|
| `pc/capture.py` | 屏幕捕获 |
|
||||||
| `pc/overlay.py` | 顶栏角色标签 + 网格「克/搭/补」方标 + 阵容分析横条(点击穿透) |
|
| `pc/overlay.py` | 网格「克/搭/补」方标 + 阵容分析横条 + 锁定后装备图标条(点击穿透;不再在顶栏头像下画定位) |
|
||||||
| `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 |
|
| `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 |
|
||||||
| `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py`) |
|
| `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py`) |
|
||||||
| `pc/config.json` | 相对坐标、阈值、GSI、recommend 参数(`relations_path` 指向 `shared/data/relations.json`) |
|
| `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/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/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,Web 版本页只读) |
|
||||||
| `web/data/hero_grid_order.json` | 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_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/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 |
|
| `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 模板 + 可选天梯遮罩)
|
→ recognize_image (CDN 模板 + 可选天梯遮罩)
|
||||||
→ roles(分路字)/ shared.grid(禁用 + 单元格)/ modes
|
→ roles(分路字)/ shared.grid(禁用 + 单元格)/ modes
|
||||||
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI)
|
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI)
|
||||||
→ overlay 角色标签 + 网格克/搭/补 + 阵容分析条(可选)
|
→ overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选)
|
||||||
→ pc/results/draft_*.json + 终端时间线
|
→ pc/results/draft_*.json + 终端时间线
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
|
|||||||
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
|
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
|
||||||
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
|
- **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 软失败刷新粉丝等字段(不阻断整档)。
|
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `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。
|
- **机制查询页**:顶层 `/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
|
||||||
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
|
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
|
||||||
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
||||||
@@ -223,7 +224,7 @@ python pc/evaluate.py
|
|||||||
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
|
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
|
||||||
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
|
- 改 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`)。
|
- 改 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。
|
- 发版上分帝 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`。
|
- 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -160,7 +160,7 @@ GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_ove
|
|||||||
climperor/
|
climperor/
|
||||||
├── pc/ # 局内选将识别(GSI + 截屏 + OpenCV)
|
├── pc/ # 局内选将识别(GSI + 截屏 + OpenCV)
|
||||||
│ ├── common.py / recognize.py / draft_session.py / gsi_watch.py
|
│ ├── 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/
|
│ ├── config.json / templates/ / assets/role_icons/ / samples/
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── web/ # 上分帝 Web(前端、数据流水线、部署)
|
├── web/ # 上分帝 Web(前端、数据流水线、部署)
|
||||||
@@ -200,11 +200,12 @@ climperor/
|
|||||||
| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true |
|
| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true |
|
||||||
| `recommend.relations_path` | 定性关系文件 | `shared/data/relations.json` |
|
| `recommend.relations_path` | 定性关系文件 | `shared/data/relations.json` |
|
||||||
| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位;非定位局不过滤) | 见 config.json |
|
| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位;非定位局不过滤) | 见 config.json |
|
||||||
| `overlay.enabled` | 选将顶栏角色标签悬浮层 | true |
|
| `recommend.items.*` | 锁定后核心装+应对装(`hero_items` + 定性规则) | 见 config.json |
|
||||||
| `overlay.y_gap_rel` / `icon_h_rel` / `icon_gap_rel` | 标签相对屏幕高的间距与尺寸 | 0.008 / 0.016 / 0.002 |
|
| `overlay.enabled` | 选将网格「克/搭/补」+ 分析条 + 装备图标条 | true |
|
||||||
| `overlay.mark_size_rel` / `mark_pad_rel` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 |
|
| `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.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` |
|
||||||
| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json |
|
| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json |
|
||||||
|
| `overlay.items_*` / `item_*` | 锁定后装备图标条位置/尺寸/底色 | 见 config.json |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.6.4] - 2026-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -233,7 +233,8 @@ components:
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail`
|
桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail`
|
||||||
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度(约 `min(640px, …)`),
|
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度:按视口预留顶栏 + 约 24% 英雄网格 +
|
||||||
|
定位标签后取余,再 `clamp` 到约 `320–640px`(CSS 回退 `min(640px, calc(100dvh - 17.5rem))`),
|
||||||
Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。
|
Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。
|
||||||
技能演示片源为官网 16:9;有横向空间时加宽(上限约 720px / 列宽 58%),
|
技能演示片源为官网 16:9;有横向空间时加宽(上限约 720px / 列宽 58%),
|
||||||
`aspect-ratio: 16/9` + `object-fit: contain`,勿用拉满高度的 `cover` 裁左右。
|
`aspect-ratio: 16/9` + `object-fit: contain`,勿用拉满高度的 `cover` 裁左右。
|
||||||
|
|||||||
+18
-6
@@ -102,7 +102,7 @@
|
|||||||
"min_iou": 0.55
|
"min_iou": 0.55
|
||||||
},
|
},
|
||||||
"recommend": {
|
"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,
|
"enabled": true,
|
||||||
"top_n": 0,
|
"top_n": 0,
|
||||||
"min_enemies": 1,
|
"min_enemies": 1,
|
||||||
@@ -115,6 +115,15 @@
|
|||||||
"3": ["Initiator", "Durable", "Carry"],
|
"3": ["Initiator", "Durable", "Carry"],
|
||||||
"4": ["Support"],
|
"4": ["Support"],
|
||||||
"5": ["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": {
|
"gsi": {
|
||||||
@@ -138,11 +147,8 @@
|
|||||||
"dump_payloads": true
|
"dump_payloads": true
|
||||||
},
|
},
|
||||||
"overlay": {
|
"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,
|
"enabled": true,
|
||||||
"y_gap_rel": 0.008,
|
|
||||||
"icon_h_rel": 0.016,
|
|
||||||
"icon_gap_rel": 0.002,
|
|
||||||
"mark_size_rel": 0.018,
|
"mark_size_rel": 0.018,
|
||||||
"mark_pad_rel": 0.004,
|
"mark_pad_rel": 0.004,
|
||||||
"mark_gap_rel": 0.002,
|
"mark_gap_rel": 0.002,
|
||||||
@@ -154,7 +160,13 @@
|
|||||||
"analysis_h_rel": 0.028,
|
"analysis_h_rel": 0.028,
|
||||||
"analysis_font_rel": 0.014,
|
"analysis_font_rel": 0.014,
|
||||||
"analysis_bg": "#1a2332",
|
"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"
|
"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 shared.grid import bans, hero_table, read_grid
|
||||||
from modes import detect_mode, load_mode_templates
|
from modes import detect_mode, load_mode_templates
|
||||||
from recognize import recognize_image
|
from recognize import recognize_image
|
||||||
|
from item_suggest import suggest_items
|
||||||
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
|
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
|
||||||
from roles import ROLES, detect_roles, load_role_templates
|
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_archetypes = bool(rec.get("archetypes", True))
|
||||||
self.recommend_role_tags = rec.get("role_tags")
|
self.recommend_role_tags = rec.get("role_tags")
|
||||||
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None
|
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._rec_warned = False
|
||||||
self._last_rec_sig: tuple | None = None
|
self._last_rec_sig: tuple | None = None
|
||||||
self._last_enemy_profile: dict = {}
|
self._last_enemy_profile: dict = {}
|
||||||
self._last_rec_meta: dict = {}
|
self._last_rec_meta: dict = {}
|
||||||
|
self._last_item_sig: tuple | None = None
|
||||||
|
|
||||||
def _push_overlay(self, confirmed: dict[int, str]) -> None:
|
def _push_overlay(self, confirmed: dict[int, str]) -> None:
|
||||||
if self.overlay is None:
|
if self.overlay is None:
|
||||||
@@ -113,6 +122,7 @@ class DraftSession:
|
|||||||
cells: dict | None,
|
cells: dict | None,
|
||||||
picks: list[dict],
|
picks: list[dict],
|
||||||
analysis: str = "",
|
analysis: str = "",
|
||||||
|
items: list[dict] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.overlay is None:
|
if self.overlay is None:
|
||||||
return
|
return
|
||||||
@@ -121,6 +131,8 @@ class DraftSession:
|
|||||||
self.overlay.set_grid_marks(cells or {}, marks)
|
self.overlay.set_grid_marks(cells or {}, marks)
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
self.overlay.set_analysis(analysis or "")
|
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
|
except Exception as e: # noqa: BLE001
|
||||||
self.log(f"[draft] rec overlay update failed: {e}")
|
self.log(f"[draft] rec overlay update failed: {e}")
|
||||||
|
|
||||||
@@ -134,9 +146,12 @@ class DraftSession:
|
|||||||
self.overlay.set_grid_marks({}, {})
|
self.overlay.set_grid_marks({}, {})
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
self.overlay.set_analysis("")
|
self.overlay.set_analysis("")
|
||||||
|
if hasattr(self.overlay, "set_items"):
|
||||||
|
self.overlay.set_items([])
|
||||||
self.overlay.show()
|
self.overlay.show()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
self.log(f"[draft] overlay show failed: {e}")
|
self.log(f"[draft] overlay show failed: {e}")
|
||||||
|
self._last_item_sig = None
|
||||||
if self.recommend_enabled and not self._rec_warned:
|
if self.recommend_enabled and not self._rec_warned:
|
||||||
rel = self.relations or {}
|
rel = self.relations or {}
|
||||||
if not rel.get("counters") and not rel.get("synergies"):
|
if not rel.get("counters") and not rel.get("synergies"):
|
||||||
@@ -157,6 +172,7 @@ class DraftSession:
|
|||||||
"cells": None,
|
"cells": None,
|
||||||
"mode": None,
|
"mode": None,
|
||||||
"recommendations": [],
|
"recommendations": [],
|
||||||
|
"item_recommendations": [],
|
||||||
}
|
}
|
||||||
polls = 0
|
polls = 0
|
||||||
last_frame = None
|
last_frame = None
|
||||||
@@ -248,8 +264,8 @@ class DraftSession:
|
|||||||
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
|
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
|
||||||
timeline.append(event)
|
timeline.append(event)
|
||||||
self._log_event(event, info)
|
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)
|
self._refresh_recommendations(confirmed, info, gsi_fn)
|
||||||
|
|
||||||
n = len(confirmed)
|
n = len(confirmed)
|
||||||
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
|
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({}, {})
|
self.overlay.set_grid_marks({}, {})
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
self.overlay.set_analysis("")
|
self.overlay.set_analysis("")
|
||||||
|
if hasattr(self.overlay, "set_items"):
|
||||||
|
self.overlay.set_items([])
|
||||||
self.overlay.hide()
|
self.overlay.hide()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
self.log(f"[draft] overlay hide failed: {e}")
|
self.log(f"[draft] overlay hide failed: {e}")
|
||||||
@@ -383,26 +401,70 @@ class DraftSession:
|
|||||||
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
|
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
|
||||||
f"(contrast margin {res['margin']}) - {names}")
|
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:
|
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
|
||||||
if not self.recommend_enabled:
|
if not self.recommend_enabled:
|
||||||
return
|
return
|
||||||
if not self.relations and not self.recommend_archetypes:
|
|
||||||
return
|
|
||||||
gsi = (gsi_fn() or {}) if gsi_fn else {}
|
gsi = (gsi_fn() or {}) if gsi_fn else {}
|
||||||
self_slot = gsi_slot(gsi) or info.get("self_slot")
|
self_slot = gsi_slot(gsi) or info.get("self_slot")
|
||||||
self_team = gsi.get("team") or info.get("self_team")
|
self_team = gsi.get("team") or info.get("self_team")
|
||||||
role = info["roles"].get(self_slot) if self_slot else None
|
role = info["roles"].get(self_slot) if self_slot else None
|
||||||
position = role["position"] if role else None
|
position = role["position"] if role else None
|
||||||
# Stop suggesting once you have locked a hero.
|
self_hero = (confirmed.get(self_slot) if self_slot else None) or gsi.get("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
|
|
||||||
enemies = enemy_keys(confirmed, self_team)
|
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)
|
allies = ally_keys(confirmed, self_team, self_slot)
|
||||||
exclude = set(confirmed.values())
|
exclude = set(confirmed.values())
|
||||||
if info.get("unavailable"):
|
if info.get("unavailable"):
|
||||||
@@ -411,11 +473,13 @@ class DraftSession:
|
|||||||
if not force and sig == self._last_rec_sig:
|
if not force and sig == self._last_rec_sig:
|
||||||
return
|
return
|
||||||
self._last_rec_sig = sig
|
self._last_rec_sig = sig
|
||||||
|
self._last_item_sig = None
|
||||||
if len(enemies) < self.recommend_min_enemies:
|
if len(enemies) < self.recommend_min_enemies:
|
||||||
info["recommendations"] = []
|
info["recommendations"] = []
|
||||||
|
info["item_recommendations"] = []
|
||||||
self._last_enemy_profile = {}
|
self._last_enemy_profile = {}
|
||||||
self._last_rec_meta = {}
|
self._last_rec_meta = {}
|
||||||
self._push_rec_overlay(info.get("cells"), [], "")
|
self._push_rec_overlay(info.get("cells"), [], "", [])
|
||||||
return
|
return
|
||||||
result = suggest_marks(
|
result = suggest_marks(
|
||||||
position=position,
|
position=position,
|
||||||
@@ -433,6 +497,7 @@ class DraftSession:
|
|||||||
profile = result.get("enemy_profile") or {}
|
profile = result.get("enemy_profile") or {}
|
||||||
analysis = result.get("analysis") or ""
|
analysis = result.get("analysis") or ""
|
||||||
info["recommendations"] = picks
|
info["recommendations"] = picks
|
||||||
|
info["item_recommendations"] = []
|
||||||
self._last_enemy_profile = profile
|
self._last_enemy_profile = profile
|
||||||
self._last_rec_meta = {
|
self._last_rec_meta = {
|
||||||
"analysis": analysis,
|
"analysis": analysis,
|
||||||
@@ -441,7 +506,7 @@ class DraftSession:
|
|||||||
"ally_gaps": list(result.get("ally_gaps") or []),
|
"ally_gaps": list(result.get("ally_gaps") or []),
|
||||||
"ally_profile": dict(result.get("ally_profile") 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:
|
if picks or analysis:
|
||||||
bits = []
|
bits = []
|
||||||
for p in picks[:12]:
|
for p in picks[:12]:
|
||||||
@@ -597,6 +662,7 @@ class DraftSession:
|
|||||||
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
||||||
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
|
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
|
||||||
"picks": info.get("recommendations") or [],
|
"picks": info.get("recommendations") or [],
|
||||||
|
"items": info.get("item_recommendations") or [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if info["unavailable"] is not None:
|
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)
|
name = p.get("name_loc") or loc(p["key"], names)
|
||||||
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
||||||
lines.append(f"rec : {len(picks)} — {', '.join(bits)}")
|
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
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ class Watcher:
|
|||||||
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
|
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
|
||||||
try:
|
try:
|
||||||
self.overlay = DraftOverlay(cfg)
|
self.overlay = DraftOverlay(cfg)
|
||||||
print("[draft] role-tag overlay ready", flush=True)
|
print("[draft] overlay ready", flush=True)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f"[draft] overlay disabled: {e}", flush=True)
|
print(f"[draft] overlay disabled: {e}", flush=True)
|
||||||
self.overlay = None
|
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(),
|
Runs a Tk root on a background thread. DraftSession calls set_grid_marks(),
|
||||||
set_grid_marks(), and set_analysis(); geometry uses relative coords.
|
set_analysis(), and set_items(); geometry uses relative coords.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,26 +11,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
import mss
|
import mss
|
||||||
|
|
||||||
from common import HEROES_JSON as HEROES_PATH, ROOT, slot_rect_px
|
from shared.paths import ITEM_ICONS
|
||||||
|
|
||||||
ROLE_ICONS_DIR = ROOT / "assets" / "role_icons"
|
|
||||||
|
|
||||||
ROLE_ORDER = [
|
|
||||||
"Carry",
|
|
||||||
"Support",
|
|
||||||
"Nuker",
|
|
||||||
"Disabler",
|
|
||||||
"Durable",
|
|
||||||
"Escape",
|
|
||||||
"Initiator",
|
|
||||||
"Pusher",
|
|
||||||
]
|
|
||||||
|
|
||||||
CHROMA = "#ff00ff"
|
CHROMA = "#ff00ff"
|
||||||
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
||||||
@@ -39,6 +25,9 @@ DEFAULT_FILL_COLOR = "#9b7ebd"
|
|||||||
DEFAULT_MARK_TEXT = "#0b1220"
|
DEFAULT_MARK_TEXT = "#0b1220"
|
||||||
DEFAULT_ANALYSIS_BG = "#1a2332"
|
DEFAULT_ANALYSIS_BG = "#1a2332"
|
||||||
DEFAULT_ANALYSIS_FG = "#e8eef7"
|
DEFAULT_ANALYSIS_FG = "#e8eef7"
|
||||||
|
DEFAULT_ITEM_CORE_BG = "#1a2332"
|
||||||
|
DEFAULT_ITEM_ANSWER_BG = "#2a1f14"
|
||||||
|
DEFAULT_ITEM_REASON_FG = "#e8eef7"
|
||||||
LABEL_ORDER = ("克", "搭", "补")
|
LABEL_ORDER = ("克", "搭", "补")
|
||||||
LABEL_COLORS = {
|
LABEL_COLORS = {
|
||||||
"克": "counter",
|
"克": "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]:
|
def _primary_monitor_size() -> tuple[int, int]:
|
||||||
with mss.MSS() as sct:
|
with mss.MSS() as sct:
|
||||||
mon = sct.monitors[1]
|
mon = sct.monitors[1]
|
||||||
@@ -83,9 +65,6 @@ class DraftOverlay:
|
|||||||
def __init__(self, cfg: dict):
|
def __init__(self, cfg: dict):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
o = cfg.get("overlay") or {}
|
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_size_rel = float(o.get("mark_size_rel", 0.018))
|
||||||
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
|
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
|
||||||
self.mark_gap_rel = float(o.get("mark_gap_rel", 0.002))
|
self.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_font_rel = float(o.get("analysis_font_rel", 0.014))
|
||||||
self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG))
|
self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG))
|
||||||
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
|
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
|
||||||
self.roles_by_key = _load_roles_by_key()
|
self.items_y_rel = float(o.get("items_y_rel", 0.16))
|
||||||
self._roster: dict[int, str] = {}
|
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._cells: dict[str, dict] = {}
|
||||||
self._marks: dict[str, list[str]] = {}
|
self._marks: dict[str, list[str]] = {}
|
||||||
self._analysis = ""
|
self._analysis = ""
|
||||||
|
self._items: list[dict] = []
|
||||||
self._ready = threading.Event()
|
self._ready = threading.Event()
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._root: tk.Tk | None = None
|
self._root: tk.Tk | None = None
|
||||||
@@ -137,7 +122,6 @@ class DraftOverlay:
|
|||||||
canvas.pack(fill="both", expand=True)
|
canvas.pack(fill="both", expand=True)
|
||||||
self._canvas = canvas
|
self._canvas = canvas
|
||||||
self._screen = (sw, sh)
|
self._screen = (sw, sh)
|
||||||
self._load_icon_sources()
|
|
||||||
root.update_idletasks()
|
root.update_idletasks()
|
||||||
try:
|
try:
|
||||||
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
|
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
|
||||||
@@ -157,38 +141,9 @@ class DraftOverlay:
|
|||||||
pass
|
pass
|
||||||
self._closed = True
|
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:
|
def set_roster(self, confirmed: dict[int, str]) -> None:
|
||||||
"""Update tags for confirmed slots (hero_key by slot index)."""
|
"""No-op kept for DraftSession compatibility (role tags under avatars removed)."""
|
||||||
roster = {int(k): v for k, v in confirmed.items() if v}
|
return
|
||||||
if roster == self._roster:
|
|
||||||
return
|
|
||||||
self._roster = dict(roster)
|
|
||||||
self._schedule_redraw()
|
|
||||||
|
|
||||||
def set_analysis(self, text: str | None) -> None:
|
def set_analysis(self, text: str | None) -> None:
|
||||||
"""Short lineup analysis banner (empty clears)."""
|
"""Short lineup analysis banner (empty clears)."""
|
||||||
@@ -198,6 +153,24 @@ class DraftOverlay:
|
|||||||
self._analysis = value
|
self._analysis = value
|
||||||
self._schedule_redraw()
|
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(
|
def set_grid_marks(
|
||||||
self,
|
self,
|
||||||
cells: dict[str, dict] | None,
|
cells: dict[str, dict] | None,
|
||||||
@@ -265,6 +238,7 @@ class DraftOverlay:
|
|||||||
self._canvas.delete("all")
|
self._canvas.delete("all")
|
||||||
self._photos.clear()
|
self._photos.clear()
|
||||||
self._analysis = ""
|
self._analysis = ""
|
||||||
|
self._items = []
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -296,6 +270,85 @@ class DraftOverlay:
|
|||||||
self._thread.join(timeout=2.0)
|
self._thread.join(timeout=2.0)
|
||||||
self._closed = True
|
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:
|
def _redraw(self) -> None:
|
||||||
canvas = self._canvas
|
canvas = self._canvas
|
||||||
if canvas is None:
|
if canvas is None:
|
||||||
@@ -303,9 +356,6 @@ class DraftOverlay:
|
|||||||
canvas.delete("all")
|
canvas.delete("all")
|
||||||
self._photos.clear()
|
self._photos.clear()
|
||||||
sw, sh = self._screen
|
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:
|
if self._analysis:
|
||||||
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
|
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
|
||||||
@@ -327,26 +377,7 @@ class DraftOverlay:
|
|||||||
font=("Microsoft YaHei UI", font_size, "bold"),
|
font=("Microsoft YaHei UI", font_size, "bold"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for slot in self.cfg.get("slots") or []:
|
self._draw_items(canvas, sw, sh)
|
||||||
idx = int(slot["index"])
|
|
||||||
hero = self._roster.get(idx)
|
|
||||||
if not hero:
|
|
||||||
continue
|
|
||||||
roles = [r for r in ROLE_ORDER if r in set(self.roles_by_key.get(hero, []))]
|
|
||||||
if not roles:
|
|
||||||
continue
|
|
||||||
x, y, w, h = slot_rect_px(slot, self.cfg, sw, sh)
|
|
||||||
icons = [img for r in roles if (img := self._scaled_icon(r, icon_h)) is not None]
|
|
||||||
if not icons:
|
|
||||||
continue
|
|
||||||
total_w = sum(img.width() for img in icons) + gap * (len(icons) - 1)
|
|
||||||
cx = x + w / 2
|
|
||||||
left = int(round(cx - total_w / 2))
|
|
||||||
top = y + h + y_gap
|
|
||||||
cursor = left
|
|
||||||
for img in icons:
|
|
||||||
canvas.create_image(cursor, top, image=img, anchor="nw")
|
|
||||||
cursor += img.width() + gap
|
|
||||||
|
|
||||||
mark = max(12, int(round(self.mark_size_rel * sh)))
|
mark = max(12, int(round(self.mark_size_rel * sh)))
|
||||||
pad = max(2, int(round(self.mark_pad_rel * sh)))
|
pad = max(2, int(round(self.mark_pad_rel * sh)))
|
||||||
|
|||||||
@@ -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
|
Blocks are separated by a visibly wider gutter than the gap between two
|
||||||
cards in the same block.
|
cards in the same block.
|
||||||
"""
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
|
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
|
||||||
if not gaps:
|
if not gaps:
|
||||||
return [0] * len(cols)
|
return [0] * len(cols)
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ HERO_PORTRAITS = WEB_ASSETS / "hero_portraits"
|
|||||||
ATTR_ICONS = WEB_ASSETS / "attr_icons"
|
ATTR_ICONS = WEB_ASSETS / "attr_icons"
|
||||||
# Valve hero-selection role filter icons, chroma keyed to transparent PNGs.
|
# Valve hero-selection role filter icons, chroma keyed to transparent PNGs.
|
||||||
ROLE_ICONS = WEB_ASSETS / "role_icons"
|
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"
|
ITEM_ICONS = WEB_ASSETS / "item_icons"
|
||||||
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
|
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
|
||||||
ITEM_CAT_ICONS = WEB_ASSETS / "item_cat_icons"
|
ITEM_CAT_ICONS = WEB_ASSETS / "item_cat_icons"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ from shared.paths import (
|
|||||||
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
||||||
from serve_relations import WEB_DIR, build_payload
|
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"
|
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+43
-2
@@ -445,10 +445,40 @@ let detailDrawerDrag = null;
|
|||||||
let detailDrawerSuppressClickUntil = 0;
|
let detailDrawerSuppressClickUntil = 0;
|
||||||
let detailDrawerClosing = false;
|
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() {
|
function detailDrawerFullHeight() {
|
||||||
|
const h = applyDetailDrawerHeight();
|
||||||
const panel = $("#detail");
|
const panel = $("#detail");
|
||||||
if (panel && panel.offsetHeight) return panel.offsetHeight;
|
if (panel && panel.offsetHeight) return panel.offsetHeight;
|
||||||
return Math.min(720, Math.round((window.innerHeight || 800) * 0.88));
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDetailDrawer() {
|
function syncDetailDrawer() {
|
||||||
@@ -463,6 +493,7 @@ function syncDetailDrawer() {
|
|||||||
resetDetailDrawerDrag();
|
resetDetailDrawerDrag();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
applyDetailDrawerHeight();
|
||||||
drawer.classList.remove("is-collapsed");
|
drawer.classList.remove("is-collapsed");
|
||||||
drawer.style.maxHeight = "";
|
drawer.style.maxHeight = "";
|
||||||
}
|
}
|
||||||
@@ -6090,7 +6121,17 @@ async function main() {
|
|||||||
if (t.closest && t.closest(".skill-icon.talent-trigger")) return;
|
if (t.closest && t.closest(".skill-icon.talent-trigger")) return;
|
||||||
closeTalentPopover();
|
closeTalentPopover();
|
||||||
});
|
});
|
||||||
window.addEventListener("resize", closeTalentPopover);
|
window.addEventListener("resize", () => {
|
||||||
|
closeTalentPopover();
|
||||||
|
if (
|
||||||
|
state.page === "heroes" &&
|
||||||
|
state.selectedKey &&
|
||||||
|
!detailDrawerClosing &&
|
||||||
|
!detailDrawerDrag
|
||||||
|
) {
|
||||||
|
applyDetailDrawerHeight();
|
||||||
|
}
|
||||||
|
});
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
"scroll",
|
"scroll",
|
||||||
(e) => {
|
(e) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
/* 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 SITE_ORIGIN = "";
|
||||||
var ABILITY_VIDEO_BASE = "";
|
var ABILITY_VIDEO_BASE = "";
|
||||||
var STATIC_ASSET_BASE = "";
|
var STATIC_ASSET_BASE = "";
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||||
<link rel="stylesheet" href="/style.css?v=0.6.4" />
|
<link rel="stylesheet" href="/style.css?v=0.6.5" />
|
||||||
<script src="/mobile-gate.js?v=0.6.4"></script>
|
<script src="/mobile-gate.js?v=0.6.5"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||||
@@ -233,8 +233,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
||||||
|
|
||||||
<script src="/config.js?v=0.6.4"></script>
|
<script src="/config.js?v=0.6.5"></script>
|
||||||
<script src="/router.js?v=0.6.4"></script>
|
<script src="/router.js?v=0.6.5"></script>
|
||||||
<script src="/app.js?v=0.6.4"></script>
|
<script src="/app.js?v=0.6.5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -477,9 +477,10 @@ body.detail-drawer-open .hero-role-toolbar {
|
|||||||
overflow: auto;
|
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-drawer {
|
||||||
--detail-fixed-h: min(720px, 88dvh);
|
--detail-fixed-h: min(640px, calc(100dvh - 17.5rem));
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|||||||
Reference in New Issue
Block a user