/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION */ function trimBase(raw) { return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : ""; } /** OSS base for ability demos; empty => same-origin `/ability-video/...`. */ function abilityVideoBase() { return trimBase(ABILITY_VIDEO_BASE); } function abilityVideoUrl(heroKey, abilityKey) { const rel = `/ability-video/${heroKey}/${abilityKey}.webm`; const base = abilityVideoBase(); return base ? `${base}${rel}` : rel; } /** OSS base for icons/portraits; empty => same-origin relative paths. */ function staticAssetBase() { return trimBase(STATIC_ASSET_BASE); } function assetUrl(rel) { const clean = String(rel || "").replace(/^\//, ""); const base = staticAssetBase(); return base ? `${base}/${clean}` : clean; } function attrIconSrc(key) { return assetUrl(`attr/${key}.png`); } function portraitSrc(key) { return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide`); } function abilityIconSrc(abilityKey) { return assetUrl(`ability/${encodeURIComponent(abilityKey)}.png`); } function innateIconSrc() { return assetUrl("ability/innate.png"); } function talentTreeIconSrc() { return assetUrl("ability/talent_tree.png"); } function itemCatIconSrc(icon) { return assetUrl(`item-cat/${encodeURIComponent(icon)}`); } function siteVersionLabel() { const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : ""; return v ? `v${v}` : ""; } function renderPatchesSiteVersion() { const el = $("#patches-site-version"); if (!el) return; const label = siteVersionLabel(); el.textContent = label; el.setAttribute("aria-hidden", label ? "false" : "true"); } const state = { data: null, page: "heroes", // heroes | items | patches selectedKey: null, selectedItemKey: null, /** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */ inspect: null, detailTab: "skills", // skills | core | fears tagFilters: new Set(), query: "", itemQuery: "", /** Currently selected patch version on the 版本 page; null -> latest. */ selectedPatch: null, }; const $ = (sel) => document.querySelector(sel); function counterEdge(a, b) { return (state.data.relations.counters || []).find((e) => e.a === a && e.b === b) || null; } function synergyEdge(a, b) { const [x, y] = [a, b].slice().sort(); return (state.data.relations.synergies || []).find((e) => e.a === x && e.b === y) || null; } /** All relation kinds from selected S to peer P. */ function relationsOf(peerKey) { const s = state.selectedKey; if (!s || !peerKey || s === peerKey) return null; const out = { counters: counterEdge(s, peerKey), countered: counterEdge(peerKey, s), synergy: synergyEdge(s, peerKey), }; if (!out.counters && !out.countered && !out.synergy) return null; return out; } function matchesTags(h) { if (!state.tagFilters.size) return true; const tags = new Set(h.tags || []); for (const t of state.tagFilters) { if (!tags.has(t)) return false; } return true; } function matchesQuery(h) { const q = state.query.trim(); if (!q) return true; const qLower = q.toLowerCase(); if ((h.name_loc || "").includes(q)) return true; if ((h.name || "").toLowerCase().includes(qLower)) return true; if ((h.key || "").toLowerCase().includes(qLower)) return true; for (const a of h.aliases || []) { if (String(a).includes(q) || String(a).toLowerCase().includes(qLower)) return true; } for (const a of h.abbr || []) { if (String(a).toLowerCase().includes(qLower)) return true; } return false; } function matchesFilters(h) { return matchesTags(h) && matchesQuery(h); } function renderBoard() { if (!state.data) return; const root = $("#columns"); root.innerHTML = ""; root.className = "columns"; const { by_attr, attr_order, attr_cols, attr_labels } = state.data; for (const attr of attr_order) { const col = document.createElement("section"); col.className = "col"; col.innerHTML = `
${attr_labels[attr]}${attr_labels[attr]}
`; const grid = document.createElement("div"); grid.className = "grid"; // Server already returns in-client pick-grid order. const heroes = by_attr[attr] || []; const cols = (attr_cols && attr_cols[attr]) || 6; grid.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; for (const h of heroes) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "hero"; const abbrHint = h.abbr?.length ? " / " + h.abbr.join(", ") : ""; btn.title = `${h.name_loc}${h.aliases?.length ? " / " + h.aliases.join("、") : ""}${abbrHint}`; if (!matchesFilters(h)) btn.style.display = "none"; const marks = document.createElement("div"); marks.className = "marks"; if (h.key === state.selectedKey) { btn.classList.add("selected"); marks.innerHTML = ``; } else if (state.selectedKey) { const rel = relationsOf(h.key); if (rel) { btn.classList.add("related"); const bits = []; if (rel.counters) { btn.classList.add("is-counter"); bits.push(``); } if (rel.countered) { btn.classList.add("is-countered"); bits.push(``); } if (rel.synergy) { btn.classList.add("is-synergy"); bits.push(``); } marks.innerHTML = bits.join(""); const reasons = [ rel.counters?.reason, rel.countered?.reason, rel.synergy?.reason, ].filter(Boolean); if (reasons.length) btn.title += ` — ${reasons.join(";")}`; } else { btn.classList.add("dim"); } } const img = document.createElement("img"); img.src = portraitSrc(h.key); img.alt = h.name_loc; img.loading = "lazy"; btn.appendChild(img); btn.appendChild(marks); btn.addEventListener("click", () => onHeroClick(h.key)); grid.appendChild(btn); } col.appendChild(grid); root.appendChild(col); } } function onHeroClick(key) { state.selectedKey = state.selectedKey === key ? null : key; state.inspect = null; syncStateToUrl(); render(); } function renderTagbar() { const bar = $("#tagbar"); if (!bar) return; const order = state.data.tag_order || []; bar.innerHTML = ""; const allBtn = document.createElement("button"); allBtn.type = "button"; allBtn.textContent = "全部"; allBtn.className = state.tagFilters.size ? "" : "active"; allBtn.addEventListener("click", () => { state.tagFilters.clear(); syncStateToUrl({ replace: true }); render(); }); bar.appendChild(allBtn); for (const tag of order) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = tag; if (state.tagFilters.has(tag)) btn.classList.add("active"); btn.addEventListener("click", () => { if (state.tagFilters.has(tag)) state.tagFilters.delete(tag); else state.tagFilters.add(tag); syncStateToUrl({ replace: true }); render(); }); bar.appendChild(btn); } } function heroByKey(key) { return (state.data.heroes || []).find((h) => h.key === key) || null; } function itemMeta(id) { const items = state.data.hero_items?.items || {}; return items[String(id)] || null; } function coreItemsFor(heroKey) { const cell = (state.data.hero_items?.by_hero || {})[heroKey]; if (cell == null) return null; return Array.isArray(cell) ? cell : []; } function fearedItemsFor(heroKey) { const fears = state.data.hero_item_fears; const byHero = fears && fears.by_hero; // null → whole dataset missing; [] → this hero has no feared items if (!byHero || !Object.keys(byHero).length) return null; const cell = byHero[heroKey]; if (cell == null) return []; return Array.isArray(cell) ? cell : []; } function heroAbilityCell(heroKey) { const cell = (state.data.hero_abilities?.by_hero || {})[heroKey]; if (cell == null) return null; // Legacy: by_hero[key] was a bare ability array if (Array.isArray(cell)) return { abilities: cell, talents: [] }; return { abilities: Array.isArray(cell.abilities) ? cell.abilities : [], talents: Array.isArray(cell.talents) ? cell.talents : [], }; } /** Floating talent popover (body-level; avoids #detail overflow clipping). */ let talentPopoverEl = null; let talentPopoverHideTimer = 0; function closeTalentPopover() { if (talentPopoverHideTimer) { clearTimeout(talentPopoverHideTimer); talentPopoverHideTimer = 0; } if (talentPopoverEl) { talentPopoverEl.remove(); talentPopoverEl = null; } document.querySelectorAll(".skill-icon.talent-trigger[aria-expanded='true']").forEach((el) => { el.setAttribute("aria-expanded", "false"); }); } function positionTalentPopover(trigger) { if (!talentPopoverEl || !trigger) return; const gap = 10; const margin = 8; const r = trigger.getBoundingClientRect(); // Measure with provisional place off-screen if needed. let pr = talentPopoverEl.getBoundingClientRect(); if (pr.width < 2) { talentPopoverEl.style.left = "0px"; talentPopoverEl.style.top = "0px"; pr = talentPopoverEl.getBoundingClientRect(); } let left = r.left + r.width / 2 - pr.width / 2; let top = r.top - pr.height - gap; left = Math.max(margin, Math.min(left, window.innerWidth - pr.width - margin)); if (top < margin) { top = r.bottom + gap; talentPopoverEl.classList.add("below"); } else { talentPopoverEl.classList.remove("below"); } talentPopoverEl.style.left = `${Math.round(left)}px`; talentPopoverEl.style.top = `${Math.round(top)}px`; // Keep the beak centered on the tree icon even when the panel is clamped. const beak = talentPopoverEl.querySelector(".talent-tree-beak"); if (beak) { const iconCx = r.left + r.width / 2; const beakX = iconCx - left; beak.style.left = `${Math.round(beakX)}px`; } } function buildTalentTreeCard(talents) { const tree = document.createElement("div"); tree.className = "talent-tree"; tree.setAttribute("aria-label", "天赋树"); const treeLab = document.createElement("div"); treeLab.className = "talent-tree-label"; treeLab.textContent = "天赋树"; tree.appendChild(treeLab); const rows = document.createElement("div"); rows.className = "talent-rows"; for (const lv of [25, 20, 15, 10]) { const row = document.createElement("div"); row.className = "talent-row"; const left = talents.find((t) => t.level === lv && t.side === "left"); const right = talents.find((t) => t.level === lv && t.side === "right"); const leftEl = document.createElement("div"); leftEl.className = "talent-cell"; leftEl.textContent = left?.name_loc || "—"; leftEl.title = left?.name_loc || ""; const mid = document.createElement("div"); mid.className = "talent-level"; const midNum = document.createElement("span"); midNum.className = "talent-level-num"; midNum.textContent = String(lv); mid.appendChild(midNum); const rightEl = document.createElement("div"); rightEl.className = "talent-cell"; rightEl.textContent = right?.name_loc || "—"; rightEl.title = right?.name_loc || ""; row.appendChild(leftEl); row.appendChild(mid); row.appendChild(rightEl); rows.appendChild(row); } tree.appendChild(rows); return tree; } function openTalentPopover(trigger, talents) { if (talentPopoverHideTimer) { clearTimeout(talentPopoverHideTimer); talentPopoverHideTimer = 0; } if (talentPopoverEl) { positionTalentPopover(trigger); trigger.setAttribute("aria-expanded", "true"); return; } const pop = document.createElement("div"); pop.className = "talent-popover"; pop.setAttribute("role", "dialog"); pop.setAttribute("aria-label", "天赋树"); pop.appendChild(buildTalentTreeCard(talents)); const beak = document.createElement("div"); beak.className = "talent-tree-beak"; beak.setAttribute("aria-hidden", "true"); pop.appendChild(beak); pop.addEventListener("mouseenter", () => { if (talentPopoverHideTimer) { clearTimeout(talentPopoverHideTimer); talentPopoverHideTimer = 0; } }); pop.addEventListener("mouseleave", () => { talentPopoverHideTimer = setTimeout(closeTalentPopover, 120); }); document.body.appendChild(pop); talentPopoverEl = pop; trigger.setAttribute("aria-expanded", "true"); positionTalentPopover(trigger); } function wireTalentTrigger(btn, talents) { btn.addEventListener("mouseenter", () => openTalentPopover(btn, talents)); btn.addEventListener("mouseleave", () => { talentPopoverHideTimer = setTimeout(closeTalentPopover, 120); }); btn.addEventListener("click", (e) => { e.stopPropagation(); if (talentPopoverEl && btn.getAttribute("aria-expanded") === "true") { closeTalentPopover(); } else { openTalentPopover(btn, talents); } }); } /** * Ability icon loader. * Innates (and shard/scepter chips of innate abilities) use the shared gold-droplet * badge — many innate keys 404 on Steam CDN, matching dota2.com. * Other abilities load `ability/{key}.png` with a quiet miss state on error. */ function setAbilityIcon(img, abilityKey, isInnate) { if (isInnate) { img.src = innateIconSrc(); img.onerror = null; return; } img.src = abilityIconSrc(abilityKey); img.onerror = () => { img.onerror = null; img.removeAttribute("src"); img.alt = ""; img.classList.add("missing"); }; } /** Copy dota2.com detail-pane fields from a raw ability row onto an entry. */ const ABILITY_DETAIL_FIELDS = [ "target_label", "affects_label", "damage_label", "immunity_label", "cast_range", "cast_point", "channel_time", "cooldown", "mana_cost", "lore_loc", ]; function withAbilityDetail(ent, ab) { for (const f of ABILITY_DETAIL_FIELDS) ent[f] = ab[f] || ""; ent.specials = Array.isArray(ab.specials) ? ab.specials : []; return ent; } /** Build icon-bar entries: base skills + shard/scepter upgrades (dota2.com style). * Cached per hero key; invalidated when data reloads. */ const _skillEntriesCache = new Map(); function skillEntriesFor(heroKey) { if (_skillEntriesCache.has(heroKey)) return _skillEntriesCache.get(heroKey); const cell = heroAbilityCell(heroKey); if (cell == null) return null; const entries = []; const base = []; for (const ab of cell.abilities) { if (ab.granted_by_shard || ab.granted_by_scepter) continue; base.push( withAbilityDetail( { id: `ability:${ab.key}`, kind: "ability", ability_key: ab.key, name_loc: ab.name_loc || ab.key, desc_loc: ab.desc_loc || "", label: ab.is_innate ? "先天技能" : "", dispellable: ab.dispellable || "none", is_innate: !!ab.is_innate, }, ab ) ); } // Official order among abilities: innate (circular) first, then Q/W/E/R. // Talent-tree trigger is prepended separately in the icon row. for (const ent of base) { if (ent.is_innate) entries.push(ent); } for (const ent of base) { if (!ent.is_innate) entries.push(ent); } for (const ab of cell.abilities) { if (ab.granted_by_shard || (ab.shard_loc && ab.has_shard)) { entries.push( withAbilityDetail( { id: `shard:${ab.key}`, kind: "shard", ability_key: ab.key, name_loc: ab.name_loc || ab.key, desc_loc: ab.granted_by_shard ? ab.desc_loc || ab.shard_loc || "" : ab.shard_loc || ab.desc_loc || "", label: "魔晶技能升级", dispellable: ab.dispellable || "none", // Keep innate flag so Aghs chips of innates (e.g. ember_spirit_immolation) // use the shared droplet icon + circular style; CDN has no per-key PNG. is_innate: !!ab.is_innate, }, ab ) ); } } for (const ab of cell.abilities) { if (ab.granted_by_scepter || (ab.scepter_loc && ab.has_scepter)) { entries.push( withAbilityDetail( { id: `scepter:${ab.key}`, kind: "scepter", ability_key: ab.key, name_loc: ab.name_loc || ab.key, desc_loc: ab.granted_by_scepter ? ab.desc_loc || ab.scepter_loc || "" : ab.scepter_loc || ab.desc_loc || "", label: "神杖技能升级", dispellable: ab.dispellable || "none", is_innate: !!ab.is_innate, }, ab ) ); } } const result = { entries, talents: cell.talents }; _skillEntriesCache.set(heroKey, result); return result; } const DISPEL_LABEL = { yes: "可驱散", strong_only: "仅强驱散", no: "不可驱散", none: "", }; function selectInspectSkill(id) { state.inspect = id ? { type: "skill", id } : null; renderDetail(); } function selectInspectItem(key) { if (!key) return; state.inspect = { type: "item", key }; renderDetail(); } function resolveItemDetail(key) { if (!key) return null; const shop = shopItem(key); if (shop) return shop; const metaExtra = (state.data.items_meta || {})[key] || null; const items = state.data.hero_items?.items || {}; for (const row of Object.values(items)) { if (row && row.key === key) { return { key, name_loc: row.name_loc || row.dname || metaExtra?.name_loc || key, dname: row.dname || key, cost: row.cost ?? metaExtra?.cost, components: metaExtra?.components || [], builds_into: metaExtra?.builds_into || [], desc_loc: metaExtra?.desc_loc || row.desc_loc || "", }; } } if (metaExtra) { return { key, name_loc: metaExtra.name_loc || key, cost: metaExtra.cost, components: metaExtra.components || [], builds_into: metaExtra.builds_into || [], desc_loc: metaExtra.desc_loc || "", }; } return { key, name_loc: key, components: [], builds_into: [] }; } function appendItemIcon(list, { key, name, title }) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "item"; btn.title = title || name || key || ""; if ( key && state.inspect && state.inspect.type === "item" && state.inspect.key === key ) { btn.classList.add("selected"); } if (key) { const img = document.createElement("img"); img.src = itemIconSrc(key); img.alt = name || key; img.loading = "lazy"; btn.appendChild(img); btn.addEventListener("click", () => selectInspectItem(key)); } else { btn.textContent = name || "?"; btn.disabled = true; } list.appendChild(btn); } function buildSkillsPanel(heroKey) { closeTalentPopover(); const absBlock = document.createElement("div"); absBlock.className = "detail-abilities"; absBlock.setAttribute("aria-label", "技能"); const skillPack = skillEntriesFor(heroKey); if (skillPack == null) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无技能数据(请运行 python fetch_hero_abilities.py)"; absBlock.appendChild(miss); return absBlock; } const { entries, talents } = skillPack; const activeId = state.inspect?.type === "skill" ? state.inspect.id : null; const icons = document.createElement("div"); icons.className = "skill-icons"; icons.setAttribute("role", "tablist"); icons.setAttribute("aria-label", "技能与升级"); if (talents.length) { const talentBtn = document.createElement("button"); talentBtn.type = "button"; talentBtn.className = "skill-icon talent-trigger"; talentBtn.setAttribute("aria-label", "天赋树"); talentBtn.setAttribute("aria-expanded", "false"); talentBtn.setAttribute("aria-haspopup", "dialog"); talentBtn.title = "天赋树"; const talentImg = document.createElement("img"); talentImg.src = talentTreeIconSrc(); talentImg.alt = "天赋树"; talentImg.loading = "lazy"; talentBtn.appendChild(talentImg); wireTalentTrigger(talentBtn, talents); icons.appendChild(talentBtn); } for (const ent of entries) { const btn = document.createElement("button"); btn.type = "button"; const isSel = !!(activeId && ent.id === activeId); btn.className = "skill-icon" + (ent.kind !== "ability" ? ` kind-${ent.kind}` : "") + (ent.is_innate ? " innate" : "") + (isSel ? " active selected" : ""); btn.setAttribute("role", "tab"); btn.setAttribute("aria-selected", isSel ? "true" : "false"); btn.title = [ent.name_loc, ent.label].filter(Boolean).join(" · "); const img = document.createElement("img"); img.alt = ent.name_loc; img.loading = "lazy"; setAbilityIcon(img, ent.ability_key, !!ent.is_innate); btn.appendChild(img); if (ent.kind === "shard" || ent.kind === "scepter") { const mark = document.createElement("span"); mark.className = "skill-aghs-mark"; mark.textContent = ent.kind === "shard" ? "晶" : "A"; btn.appendChild(mark); } btn.addEventListener("click", () => selectInspectSkill(ent.id)); icons.appendChild(btn); } absBlock.appendChild(icons); return absBlock; } function buildCoreItemsPanel(heroKey) { const block = document.createElement("div"); block.className = "detail-items detail-tab-panel"; block.setAttribute("aria-label", "核心装备"); const entries = coreItemsFor(heroKey); if (entries == null) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无装备数据(请运行 python fetch_hero_items.py)"; block.appendChild(miss); return block; } const list = document.createElement("div"); list.className = "item-list"; if (!entries.length) { const empty = document.createElement("span"); empty.className = "detail-muted"; empty.textContent = "暂无"; list.appendChild(empty); } else { for (const entry of entries) { const meta = itemMeta(entry.id); const key = meta?.key; const name = meta?.name_loc || meta?.dname || (key ? key : `#${entry.id}`); appendItemIcon(list, { key, name, title: `${name}${entry.count != null ? ` (${entry.count})` : ""}`, }); } } block.appendChild(list); return block; } function buildFearItemsPanel(heroKey) { const block = document.createElement("div"); block.className = "detail-items detail-tab-panel"; block.setAttribute("aria-label", "被克装备"); const fears = fearedItemsFor(heroKey); const list = document.createElement("div"); list.className = "item-list"; if (fears == null) { const miss = document.createElement("span"); miss.className = "detail-muted"; miss.textContent = "暂无克制装备数据(请运行 python fetch_items_meta.py && python fetch_hero_abilities.py && python item_fears.py)"; list.appendChild(miss); } else if (!fears.length) { const empty = document.createElement("span"); empty.className = "detail-muted"; empty.textContent = "暂无"; list.appendChild(empty); } else { for (const entry of fears) { const key = entry.item; const name = entry.name_loc || key; const reason = entry.reason || ""; const tip = [name, reason].filter(Boolean).join(" — "); appendItemIcon(list, { key, name, title: tip }); } } block.appendChild(list); return block; } function mountSkillInspect(root, skillEnt, heroKey) { root.classList.remove("empty"); // Head: name (+ shard/scepter label). The large icon is intentionally // omitted — the selected skill is already highlighted in the icon row above. const head = document.createElement("div"); head.className = "skill-info-head"; const titles = document.createElement("div"); titles.className = "skill-info-titles"; const nameEl = document.createElement("div"); nameEl.className = "skill-info-name"; nameEl.textContent = skillEnt.name_loc; titles.appendChild(nameEl); if (skillEnt.label) { const lab = document.createElement("div"); lab.className = "skill-info-label" + (skillEnt.kind === "shard" ? " shard" : skillEnt.kind === "scepter" ? " scepter" : ""); lab.textContent = skillEnt.label; titles.appendChild(lab); } head.appendChild(titles); // Official-page layout: demo clip on the left, name/desc column on the right. const body = document.createElement("div"); body.className = "skill-info-body"; // Official demo clip: local `/ability-video/...` (serve_relations) or OSS base // from config.js (static export / Pages). Hidden when the file is missing. if (heroKey && skillEnt.ability_key) { const video = document.createElement("video"); video.className = "skill-info-video"; video.src = abilityVideoUrl(heroKey, skillEnt.ability_key); video.muted = true; video.loop = true; video.autoplay = true; video.playsInline = true; video.crossOrigin = "anonymous"; video.addEventListener("error", () => video.remove()); body.appendChild(video); } const main = document.createElement("div"); main.className = "skill-info-main"; main.appendChild(head); const desc = document.createElement("div"); desc.className = "skill-info-desc"; desc.textContent = skillEnt.desc_loc || "暂无描述"; main.appendChild(desc); // Params panel, dota2.com.cn layout: a two-column "generic" grid of meta // attributes + a flowing grid of special values, so the column fills its // width instead of stacking a single narrow list. const params = document.createElement("div"); params.className = "skill-params"; const appendParam = (parent, label, value) => { const row = document.createElement("div"); row.className = "skill-param"; const lab = document.createElement("span"); lab.className = "skill-param-label"; lab.textContent = label + ":"; const val = document.createElement("span"); val.className = "skill-param-value"; val.textContent = value; row.appendChild(lab); row.appendChild(val); parent.appendChild(row); }; const metaRows = [ ["技能", skillEnt.target_label], ["影响", skillEnt.affects_label], ["伤害类型", skillEnt.damage_label], ["无视技能免疫", skillEnt.immunity_label], [ "可被驱散", skillEnt.kind === "ability" ? DISPEL_LABEL[skillEnt.dispellable] || "" : "", ], ["施法距离", skillEnt.cast_range], ["施法前摇", skillEnt.cast_point], ["吟唱时间", skillEnt.channel_time], ].filter(([, v]) => v); if (metaRows.length) { const generic = document.createElement("div"); generic.className = "skill-generic"; for (const [label, value] of metaRows) appendParam(generic, label, value); params.appendChild(generic); } const specials = Array.isArray(skillEnt.specials) ? skillEnt.specials : []; if (specials.length) { const specific = document.createElement("div"); specific.className = "skill-specific"; for (const sp of specials) appendParam(specific, sp.label, sp.value); params.appendChild(specific); } if (params.childElementCount) { main.appendChild(params); } // Cooldown / mana footer with icons (actives only; passives have neither). const passiveLike = skillEnt.target_label === "被动" || skillEnt.target_label === "光环"; if (!passiveLike && (skillEnt.cooldown || skillEnt.mana_cost)) { const costs = document.createElement("div"); costs.className = "skill-bottom"; if (skillEnt.cooldown) { const cd = document.createElement("span"); cd.className = "skill-stat cooldown"; const ico = document.createElement("img"); ico.className = "skill-stat-icon"; ico.src = assetUrl("ui-icon/cooldown.png"); ico.alt = ""; const txt = document.createElement("span"); txt.className = "skill-stat-text"; txt.textContent = skillEnt.cooldown; cd.appendChild(ico); cd.appendChild(txt); costs.appendChild(cd); } if (skillEnt.mana_cost) { const mp = document.createElement("span"); mp.className = "skill-stat mana"; const ico = document.createElement("span"); ico.className = "skill-stat-icon mana"; const txt = document.createElement("span"); txt.className = "skill-stat-text"; txt.textContent = skillEnt.mana_cost; mp.appendChild(ico); mp.appendChild(txt); costs.appendChild(mp); } main.appendChild(costs); } // Flavor lore line. if (skillEnt.lore_loc) { const lore = document.createElement("div"); lore.className = "skill-info-lore"; lore.textContent = skillEnt.lore_loc; main.appendChild(lore); } body.appendChild(main); root.appendChild(body); } function mountItemInspect(root, meta, { onPickItem, hideIcon = false } = {}) { root.classList.remove("empty"); const wrap = document.createElement("div"); wrap.className = "item-detail-top"; const nameRow = document.createElement("div"); nameRow.className = "item-detail-name-row"; const title = document.createElement("div"); title.className = "item-detail-name"; title.textContent = meta.name_loc || meta.key; nameRow.appendChild(title); if (meta.cost != null) { const cost = document.createElement("span"); cost.className = "detail-item-cost"; cost.textContent = String(meta.cost); nameRow.appendChild(cost); } wrap.appendChild(nameRow); const head = document.createElement("div"); head.className = "item-detail-head"; if (!hideIcon) { const icon = document.createElement("img"); icon.className = "item-detail-large-icon"; icon.src = itemIconSrc(meta.key); icon.alt = meta.name_loc || meta.key; head.appendChild(icon); } const info = document.createElement("div"); info.className = "item-detail-info"; const descText = stripItemDesc(meta.desc_loc); if (descText) { const desc = document.createElement("div"); desc.className = "skill-info-desc"; desc.textContent = descText; info.appendChild(desc); } else { const muted = document.createElement("div"); muted.className = "detail-muted"; muted.textContent = meta.section_label || "物品"; info.appendChild(muted); } head.appendChild(info); wrap.appendChild(head); const comps = meta.components || []; if (comps.length) { const recipe = document.createElement("div"); recipe.className = "item-detail-recipe"; const recipeLabel = document.createElement("div"); recipeLabel.className = "craft-label"; recipeLabel.textContent = "合成"; recipe.appendChild(recipeLabel); const row = document.createElement("div"); row.className = "craft-row"; for (const key of comps) { row.appendChild(makeCraftChip(key, { onPick: onPickItem })); } recipe.appendChild(row); wrap.appendChild(recipe); } const ups = meta.builds_into || []; if (ups.length) { const upBlock = document.createElement("div"); upBlock.className = "item-detail-upgrades"; const upLabel = document.createElement("div"); upLabel.className = "craft-label"; upLabel.textContent = "可升级为"; upBlock.appendChild(upLabel); const row = document.createElement("div"); row.className = "craft-row craft-upgrades"; for (const key of ups) { row.appendChild(makeCraftChip(key, { onPick: onPickItem })); } upBlock.appendChild(row); wrap.appendChild(upBlock); } root.appendChild(wrap); } /** First normal ability (typically Q); never default to talent tree or innate. */ function defaultSkillEntry(heroKey) { const pack = skillEntriesFor(heroKey); if (!pack?.entries?.length) return null; return ( pack.entries.find((e) => e.kind === "ability" && !e.is_innate) || null ); } function defaultItemKey(heroKey) { if (state.detailTab === "core") { const entries = coreItemsFor(heroKey); if (!entries?.length) return null; return itemMeta(entries[0].id)?.key || null; } if (state.detailTab === "fears") { const fears = fearedItemsFor(heroKey); if (!fears?.length) return null; const first = fears[0]; return typeof first === "string" ? first : first?.item || first?.key || null; } return null; } function ensureHeroInspectDefault(heroKey) { if (state.inspect) { if (state.inspect.type === "skill") { const pack = skillEntriesFor(heroKey); if (pack?.entries?.some((e) => e.id === state.inspect.id)) return; } if (state.inspect.type === "item") { // Keep only if still on an items tab; otherwise fall through to tab default. if (state.detailTab === "core" || state.detailTab === "fears") return; } } if (state.detailTab === "skills") { const first = defaultSkillEntry(heroKey); if (first) { state.inspect = { type: "skill", id: first.id }; return; } } else if (state.detailTab === "core" || state.detailTab === "fears") { const key = defaultItemKey(heroKey); if (key) { state.inspect = { type: "item", key }; return; } } state.inspect = null; } function renderHeroInspect(heroKey) { const pane = document.createElement("div"); pane.className = "hero-inspect"; pane.setAttribute("aria-label", "选中详情"); ensureHeroInspectDefault(heroKey); if (!state.inspect) { pane.classList.add("empty"); pane.textContent = state.detailTab === "skills" ? "点击技能查看详情" : "点击装备查看详情与合成"; return pane; } if (state.inspect.type === "skill") { const pack = skillEntriesFor(heroKey); const ent = pack?.entries?.find((e) => e.id === state.inspect.id); if (!ent) { pane.classList.add("empty"); pane.textContent = "点击技能查看详情"; return pane; } mountSkillInspect(pane, ent, heroKey); return pane; } const meta = resolveItemDetail(state.inspect.key); if (!meta) { pane.classList.add("empty"); pane.textContent = "未找到物品数据"; return pane; } mountItemInspect(pane, meta, { onPickItem: selectInspectItem, hideIcon: true }); return pane; } function fmtGain(v) { if (v == null || Number.isNaN(Number(v))) return ""; const n = Number(v); const s = Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, ""); return `+${s}`; } function fmtNum(v, digits = 1) { if (v == null || Number.isNaN(Number(v))) return ""; const n = Number(v); if (Number.isInteger(n)) return String(n); return n.toFixed(digits).replace(/\.?0+$/, ""); } function fmtRegen(v) { if (v == null || Number.isNaN(Number(v))) return null; return `+${fmtNum(v, 2)}`; } /** Shared HP/Mana bar scale: max base resource across all heroes (cached). */ let _resourceBarScaleCache = null; function resourceBarScale() { if (_resourceBarScaleCache != null) return _resourceBarScaleCache; let max = 0; for (const h of state.data?.heroes || []) { if (h.health != null) max = Math.max(max, Number(h.health) || 0); if (h.mana != null) max = Math.max(max, Number(h.mana) || 0); } _resourceBarScaleCache = max > 0 ? max : 1000; return _resourceBarScaleCache; } /** Fill width % on shared scale; clamp so tiny values stay visible. */ function resourceBarPct(value, scale) { const MIN_PCT = 8; const n = Number(value); if (!Number.isFinite(n) || n <= 0 || !(scale > 0)) return MIN_PCT; return Math.max(MIN_PCT, Math.min(100, (n / scale) * 100)); } function appendVitalBar(parent, kind, label, value, regen, scale) { const bar = document.createElement("div"); bar.className = `hero-stat-bar ${kind}`; const pct = resourceBarPct(value, scale); bar.innerHTML = `` + `
` + `${label}` + `${value}` + (regen ? `${regen}` : "") + `` + `
`; parent.appendChild(bar); } /** Left column: HP/Mana + STR/AGI/INT (skills tab only). */ function buildHeroVitalsAttrs(hero) { if (hero.base_str == null && hero.health == null) return null; const strip = document.createElement("div"); strip.className = "hero-stats"; strip.setAttribute("aria-label", "基础属性"); const vitals = document.createElement("div"); vitals.className = "hero-stats-vitals"; const scale = resourceBarScale(); if (hero.health != null) { appendVitalBar( vitals, "hp", "生命", hero.health, fmtRegen(hero.health_regen), scale ); } if (hero.mana != null) { appendVitalBar( vitals, "mana", "魔法", hero.mana, fmtRegen(hero.mana_regen), scale ); } if (vitals.childNodes.length) strip.appendChild(vitals); const attrs = document.createElement("div"); attrs.className = "hero-stats-attrs"; const primary = hero.attr || "all"; const attrDefs = [ { key: "str", label: "力量", base: hero.base_str, gain: hero.str_gain }, { key: "agi", label: "敏捷", base: hero.base_agi, gain: hero.agi_gain }, { key: "int", label: "智力", base: hero.base_int, gain: hero.int_gain }, ]; for (const a of attrDefs) { if (a.base == null) continue; const cell = document.createElement("div"); cell.className = "hero-stat-attr" + (primary === a.key || primary === "all" ? " primary" : ""); cell.setAttribute("title", a.label); cell.innerHTML = `${a.label}` + `${a.base}` + `${fmtGain(a.gain)}`; attrs.appendChild(cell); } if (attrs.childNodes.length) strip.appendChild(attrs); return strip.childNodes.length ? strip : null; } /** Right rail: Attack / Defense / Mobility as 3 side-by-side columns (skills tab). */ function buildHeroCombatStats(hero) { const combatGroups = [ { title: "攻击", rows: [ hero.damage_min != null && hero.damage_max != null ? ["伤害", `${hero.damage_min}–${hero.damage_max}`] : null, hero.attack_rate != null ? ["间隔", fmtNum(hero.attack_rate, 1)] : null, hero.attack_range != null ? ["距离", String(hero.attack_range)] : null, // Melee heroes often have projectile_speed 0 in OpenDota — skip those. hero.projectile_speed != null && Number(hero.projectile_speed) > 0 ? ["弹道", String(hero.projectile_speed)] : null, ].filter(Boolean), }, { title: "防御", rows: [ hero.armor != null ? ["护甲", fmtNum(hero.armor, 1)] : null, hero.magic_resist != null ? ["魔抗", `${hero.magic_resist}%`] : null, ].filter(Boolean), }, { title: "机动性", rows: [ hero.move_speed != null ? ["移速", String(hero.move_speed)] : null, hero.turn_rate != null ? ["转身", fmtNum(hero.turn_rate, 1)] : null, hero.vision_day != null && hero.vision_night != null ? ["视野", `白天:${hero.vision_day}\n夜晚:${hero.vision_night}`] : null, ].filter(Boolean), }, ].filter((g) => g.rows.length); if (!combatGroups.length) return null; const panel = document.createElement("aside"); panel.className = "hero-stats hero-stats-combat-panel"; panel.setAttribute("aria-label", "战斗属性"); const combat = document.createElement("div"); combat.className = "hero-stats-combat"; combat.style.setProperty("--combat-cols", String(combatGroups.length)); combat.style.setProperty( "--combat-rows", String(Math.max(...combatGroups.map((g) => g.rows.length))) ); for (const g of combatGroups) { const section = document.createElement("section"); section.className = "hero-stats-combat-section"; const title = document.createElement("h4"); title.className = "hero-stats-combat-title"; title.textContent = g.title; section.appendChild(title); const list = document.createElement("dl"); list.className = "hero-stats-combat-list"; for (const [label, val] of g.rows) { const row = document.createElement("div"); row.className = "hero-stat-row"; const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); if (typeof val === "string" && val.includes("\n")) { for (const line of val.split("\n")) { const div = document.createElement("div"); const idx = line.indexOf(":"); if (idx > 0) { const sub = document.createElement("span"); sub.className = "hero-stat-row-sub"; sub.textContent = line.slice(0, idx + 1); div.appendChild(sub); div.appendChild(document.createTextNode(line.slice(idx + 1))); } else { div.textContent = line; } dd.appendChild(div); } } else { dd.textContent = val; } row.appendChild(dt); row.appendChild(dd); list.appendChild(row); } section.appendChild(list); combat.appendChild(section); } panel.appendChild(combat); return panel; } function renderDetail() { const root = $("#detail"); if (!root) return; root.innerHTML = ""; if (!state.selectedKey) { root.classList.add("empty"); root.textContent = "点击英雄查看定位、技能、核心装备与被克装备"; return; } root.classList.remove("empty"); const hero = heroByKey(state.selectedKey); if (!hero) { root.classList.add("empty"); root.textContent = "未找到英雄数据"; return; } const head = document.createElement("div"); head.className = "detail-head"; const title = document.createElement("div"); title.className = "detail-name"; title.textContent = hero.name_loc || hero.key; head.appendChild(title); const tags = document.createElement("div"); tags.className = "detail-tags"; tags.setAttribute("aria-label", "英雄定位"); const tagList = hero.tags || []; if (!tagList.length) { const none = document.createElement("span"); none.className = "detail-muted"; none.textContent = "暂无定位"; tags.appendChild(none); } else { for (const t of tagList) { const chip = document.createElement("span"); chip.className = "tag-chip"; chip.textContent = t; tags.appendChild(chip); } } head.appendChild(tags); root.appendChild(head); const tabDefs = [ { id: "skills", label: "技能" }, { id: "core", label: "核心装备" }, { id: "fears", label: "被克装备" }, { id: "patches", label: "版本变更" }, ]; if (!tabDefs.some((t) => t.id === state.detailTab)) { state.detailTab = "skills"; } const tabbar = document.createElement("div"); tabbar.className = "detail-tabs"; tabbar.setAttribute("role", "tablist"); tabbar.setAttribute("aria-label", "详情分页"); for (const t of tabDefs) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "detail-tab" + (state.detailTab === t.id ? " active" : ""); btn.setAttribute("role", "tab"); btn.setAttribute("aria-selected", state.detailTab === t.id ? "true" : "false"); btn.textContent = t.label; btn.addEventListener("click", () => { state.detailTab = t.id; // Reset inspect to the tab default (first skill / first item). state.inspect = null; ensureHeroInspectDefault(state.selectedKey); syncStateToUrl(); renderDetail(); }); tabbar.appendChild(btn); } root.appendChild(tabbar); // Resolve default inspect BEFORE icon rows so .active / .selected match the pane. ensureHeroInspectDefault(state.selectedKey); // Skills: 血/蓝+三维 | 技能 | 攻击距离等. Items tabs: icons + inspect only. if (state.detailTab === "patches") { const layout = document.createElement("div"); layout.className = "detail-patches-layout"; const body = document.createElement("div"); body.className = "detail-tab-body patch-changes"; body.appendChild(buildPatchChangesPanel(state.selectedKey)); layout.appendChild(body); root.appendChild(layout); } else if (state.detailTab === "skills") { const layout = document.createElement("div"); layout.className = "detail-skills-layout"; const left = document.createElement("aside"); left.className = "detail-skills-stats"; const vitalsAttrs = buildHeroVitalsAttrs(hero); if (vitalsAttrs) left.appendChild(vitalsAttrs); layout.appendChild(left); const center = document.createElement("div"); center.className = "detail-skills-main"; center.appendChild(buildSkillsPanel(state.selectedKey)); center.appendChild(renderHeroInspect(state.selectedKey)); layout.appendChild(center); const combat = buildHeroCombatStats(hero); if (combat) { const right = document.createElement("div"); right.className = "detail-skills-combat"; right.appendChild(combat); layout.appendChild(right); } root.appendChild(layout); } else { // Same outer height as skills layout; icons + fixed inspect box. const layout = document.createElement("div"); layout.className = "detail-items-layout"; const body = document.createElement("div"); body.className = "detail-tab-body"; if (state.detailTab === "core") { body.appendChild(buildCoreItemsPanel(state.selectedKey)); } else { body.appendChild(buildFearItemsPanel(state.selectedKey)); } layout.appendChild(body); layout.appendChild(renderHeroInspect(state.selectedKey)); root.appendChild(layout); } } function shopCatalog() { return state.data.item_shop || { basic: { sections: [] }, upgraded: { sections: [] }, items: {} }; } function shopItem(key) { return (shopCatalog().items || {})[key] || null; } function matchesItemQuery(key, meta) { const q = state.itemQuery.trim(); if (!q) return true; const qLower = q.toLowerCase(); const name = meta?.name_loc || key; if (name.includes(q)) return true; if (key.toLowerCase().includes(qLower)) return true; if ((meta?.name || "").toLowerCase().includes(qLower)) return true; return false; } function onShopItemClick(key) { state.selectedItemKey = state.selectedItemKey === key ? null : key; state.selectedKey = null; syncStateToUrl(); render(); } function makeShopItemButton(key) { const meta = shopItem(key) || { key, name_loc: key }; const btn = document.createElement("button"); btn.type = "button"; btn.className = "shop-item"; const cost = meta.cost != null ? ` ${meta.cost}` : ""; btn.title = `${meta.name_loc || key}${cost}`; if (key === state.selectedItemKey) btn.classList.add("selected"); if (!matchesItemQuery(key, meta)) btn.style.display = "none"; const img = document.createElement("img"); img.src = itemIconSrc(key); img.alt = meta.name_loc || key; img.loading = "lazy"; btn.appendChild(img); if (meta.charges != null && meta.charges > 0) { const ch = document.createElement("span"); ch.className = "shop-charges"; ch.textContent = String(meta.charges); btn.appendChild(ch); } btn.addEventListener("click", () => onShopItemClick(key)); return btn; } function appendShopColumn(parent, sec) { const col = document.createElement("section"); col.className = "shop-column"; col.dataset.section = sec.id || ""; if (sec.icon) { const cat = document.createElement("img"); cat.className = "shop-column-cat"; cat.src = itemCatIconSrc(sec.icon); cat.alt = sec.label || ""; cat.title = sec.label || ""; col.appendChild(cat); } const head = document.createElement("h4"); head.className = "shop-column-label"; head.textContent = sec.label || sec.id || ""; col.appendChild(head); for (const key of sec.items || []) { col.appendChild(makeShopItemButton(key)); } parent.appendChild(col); } function renderItemShop() { const root = $("#item-shop"); if (!root) return; root.innerHTML = ""; root.className = "item-shop layout-cn"; const shop = shopCatalog(); const basicSecs = shop.basic?.sections || []; const upSecs = shop.upgraded?.sections || []; if (!basicSecs.length && !upSecs.length) { root.classList.add("empty"); root.textContent = "暂无商店数据,请运行 python fetch_item_shop.py"; return; } const basicWrap = document.createElement("div"); basicWrap.className = "shop-group"; for (const sec of basicSecs) appendShopColumn(basicWrap, sec); const upWrap = document.createElement("div"); upWrap.className = "shop-group"; for (const sec of upSecs) appendShopColumn(upWrap, sec); root.appendChild(basicWrap); root.appendChild(upWrap); } function itemIconSrc(key) { const stem = key.startsWith("recipe_") ? "recipe" : key; return assetUrl(`item/${encodeURIComponent(stem)}.png`); } function makeCraftChip(key, { clickable = true, onPick = null } = {}) { const meta = resolveItemDetail(key) || shopItem(key) || { key, name_loc: key.startsWith("recipe_") ? "卷轴" : key, }; const btn = document.createElement("button"); btn.type = "button"; btn.className = "craft-chip"; if (meta.is_recipe || key.startsWith("recipe_")) btn.classList.add("is-recipe"); const cost = meta.cost != null ? ` ${meta.cost}` : ""; btn.title = `${meta.name_loc || key}${cost}`; if (!clickable || meta.is_recipe || key.startsWith("recipe_")) { btn.disabled = true; } else { btn.addEventListener("click", () => { if (typeof onPick === "function") onPick(key); }); } const img = document.createElement("img"); img.src = itemIconSrc(key); img.alt = meta.name_loc || key; btn.appendChild(img); if (meta.is_recipe || key.startsWith("recipe_")) { const badge = document.createElement("span"); badge.className = "craft-recipe-cost"; badge.textContent = meta.cost != null ? String(meta.cost) : "卷轴"; btn.appendChild(badge); } return btn; } function stripItemDesc(text) { return String(text || "") .replace(//gi, "\n") .replace(/<\/?h1[^>]*>/gi, "\n") .replace(/<[^>]+>/g, "") .replace(/ /g, " ") .replace(/%[A-Za-z0-9_]+%/g, "?") .replace(/\n{3,}/g, "\n\n") .trim(); } function renderItemDetail() { const root = $("#item-detail-box"); if (!root) return; root.innerHTML = ""; if (!state.selectedItemKey) { root.classList.add("empty"); root.textContent = "点击物品查看详情与合成"; return; } const meta = resolveItemDetail(state.selectedItemKey); if (!meta) { root.classList.add("empty"); root.textContent = "未找到物品数据"; return; } mountItemInspect(root, meta, { onPickItem: (key) => { state.selectedItemKey = key; syncStateToUrl(); render(); }, }); } // Stat-icon labels for patch hero_notes (icon field). Attr values map to the // bundled attr/{agi,str,int,all}.png icons; everything else renders as a chip. const STAT_LABELS = { agility: "敏捷", strength: "力量", intelligence: "智力", all: "全才", agi: "敏捷", str: "力量", int: "智力", damage: "攻击力", damage_min: "最小攻击", damage_max: "最大攻击", armor: "护甲", magic_resistance: "魔抗", magic_resist: "魔抗", movement: "移速", movement_speed: "移速", attack_speed: "攻速", attack_rate: "攻速", attack_range: "攻击距离", attack_time: "攻击间隔", sight_range_day: "白天视野", sight_range_night: "夜间视野", vision_day: "白天视野", vision_night: "夜间视野", health: "生命", mana: "魔法", health_regen: "生命回复", mana_regen: "魔法回复", turn_rate: "转身", projectile_speed: "弹速", cooldown: "冷却", mana_cost: "魔法消耗", duration: "持续时间", cast_range: "施法距离", channel_time: "持续施法", }; const STAT_ATTR_ICON = { agility: "agi", strength: "str", intelligence: "int", all: "all", agi: "agi", str: "str", int: "int", }; function statChip(icon) { if (!icon) return ""; const a = STAT_ATTR_ICON[icon]; if (a) return ``; const label = STAT_LABELS[icon] || icon; return `${escapeHtml(label)}`; } function patchNoteHtml(note) { const esc = escapeHtml(note == null ? "" : String(note)); return esc.replace(/<br\s*\/?>/gi, "
"); } function renderNotesList(notes) { if (!notes || !notes.length) return ""; const items = notes.map((n) => { const indent = Math.max(0, (n.indent_level || 1) - 1) * 1.2; const raw = String(n.note || ""); if (n.hide_dot) { if (/^\s*$/i.test(raw)) { return `
  • `; } return `
  • ${patchNoteHtml(n.note)}
  • `; } return `
  • ${patchNoteHtml(n.note)}
  • `; }).join(""); return ``; } function renderHeroNotes(notes) { if (!notes || !notes.length) return ""; const items = notes.map((n) => { const indent = Math.max(0, (n.indent_level || 1) - 1) * 1.2; const chip = n.icon ? statChip(n.icon) : ""; if (n.hide_dot) { return `
  • ${chip}${patchNoteHtml(n.note)}
  • `; } return `
  • ${chip}${patchNoteHtml(n.note)}
  • `; }).join(""); return ``; } /** Cached set of innate ability keys (from hero_abilities) so patch rendering * can use the bundled innate.png badge instead of per-key CDN icons that 404. */ let _innateAbilityKeys = null; function innateAbilityKeys() { if (_innateAbilityKeys) return _innateAbilityKeys; const s = new Set(); const byHero = (state.data && state.data.hero_abilities && state.data.hero_abilities.by_hero) || {}; for (const cell of Object.values(byHero)) { for (const ab of (cell.abilities || [])) { if (ab && ab.is_innate && ab.key) s.add(ab.key); } } _innateAbilityKeys = s; return s; } function patchAbilityBlockHtml(ab, lookup) { const ameta = (lookup.abilities || {})[String(ab.ability_id)]; const akey = ameta ? ameta.key : ""; const aname = ameta ? ameta.name_loc : "# " + ab.ability_id; const innate = akey && innateAbilityKeys().has(akey); const aicon = akey ? `` : ""; return `
    ${aicon}
    ${escapeHtml(aname)}${renderNotesList(ab.ability_notes)}
    `; } function renderItemEntry(entry, lookup) { const meta = (lookup.items || {})[String(entry.ability_id)]; const key = meta ? meta.key : ""; const name = meta ? meta.name_loc : "# " + entry.ability_id; const icon = key ? `` : ""; return `
    ${icon}
    ${escapeHtml(name)}${renderNotesList(entry.ability_notes)}
    `; } function renderHeroEntry(hero, lookup) { const meta = (lookup.heroes || {})[String(hero.hero_id)]; const key = meta ? meta.key : ""; const name = meta ? meta.name_loc : "其它单位"; const portrait = key ? `` : ""; let html = `
    ${portrait}${escapeHtml(name)}
    `; html += renderHeroNotes(hero.hero_notes); for (const ab of (hero.abilities || [])) { html += patchAbilityBlockHtml(ab, lookup); } html += `
    `; return html; } function renderPatchSection(root, title, html) { if (!html) return; const sec = document.createElement("section"); sec.className = "patch-section"; sec.innerHTML = `

    ${escapeHtml(title)}

    ${html}`; root.appendChild(sec); } function buildPatchChangesPanel(heroKey) { const wrap = document.createElement("div"); wrap.className = "patch-changes"; const hero = heroByKey(heroKey); const patches = state.data.patches || []; const details = state.data.patch_details || {}; const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; if (!hero || !patches.length) { wrap.textContent = "近一年无版本改动"; return wrap; } const hid = Number(hero.id); const blocks = []; for (const p of patches) { // patches is newest-first; skip versions whose detail wasn't fetched. const det = details[p.version]; if (!det) continue; const hentry = (det.heroes || []).find((h) => Number(h.hero_id) === hid); if (!hentry) continue; const hasNotes = (hentry.hero_notes || []).length > 0; const hasAbilities = (hentry.abilities || []).length > 0; if (!hasNotes && !hasAbilities) continue; let html = `
    ${escapeHtml(p.version)}${escapeHtml(p.date || "")}
    `; html += renderHeroNotes(hentry.hero_notes); for (const ab of (hentry.abilities || [])) { html += patchAbilityBlockHtml(ab, lookup); } blocks.push(`
    ${html}
    `); } if (!blocks.length) { wrap.textContent = "近一年无版本改动"; return wrap; } wrap.innerHTML = blocks.join(""); return wrap; } function ensureDefaultPatch() { const patches = state.data?.patches || []; if (!patches.length) return; if (!state.selectedPatch || !patches.some((p) => p.version === state.selectedPatch)) { state.selectedPatch = patches[0].version; } } function renderPatches() { if (!state.data) return; const select = $("#patch-select"); const verEl = $("#patch-ver"); const root = $("#patches-detail"); if (!root) return; const patches = state.data.patches || []; if (!patches.length) { if (select) select.innerHTML = ""; if (verEl) verEl.textContent = ""; root.innerHTML = '
    暂无版本数据,请运行 python fetch_patches.py
    '; renderPatchesSiteVersion(); return; } ensureDefaultPatch(); if (select) { select.innerHTML = patches .map((p) => { const major = p.website ? " ★" : ""; const sel = p.version === state.selectedPatch ? " selected" : ""; return ``; }) .join(""); select.onchange = () => { state.selectedPatch = select.value; syncStateToUrl(); renderPatches(); const board = $("#patches-view"); if (board) board.scrollTo(0, 0); }; } const cur = patches.find((p) => p.version === state.selectedPatch) || patches[0]; if (verEl) verEl.textContent = (cur && cur.version) || ""; const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; const details = state.data.patch_details || {}; const det = details[state.selectedPatch]; if (!det) { root.innerHTML = '
    该版本详情暂未抓取,请重跑 python fetch_patches.py
    '; renderPatchesSiteVersion(); return; } root.innerHTML = ""; const generalHtml = (det.general_notes || []) .map((g) => { const t = g.title ? `

    ${escapeHtml(g.title)}

    ` : ""; return `
    ${t}${renderNotesList(g.generic)}
    `; }) .join(""); renderPatchSection(root, "综合改动", generalHtml); const itemsHtml = (det.items || []) .map((e) => renderItemEntry(e, lookup)) .join(""); renderPatchSection(root, "物品改动", itemsHtml); const neutralHtml = (det.neutral_items || []) .map((e) => { if (e.is_general_note || e.ability_id === -1) { return e.title ? `

    ${escapeHtml(e.title)}

    ` : ""; } return renderItemEntry(e, lookup); }) .join(""); renderPatchSection(root, "中立物品改动", neutralHtml); const heroesHtml = (det.heroes || []) .map((h) => renderHeroEntry(h, lookup)) .join(""); renderPatchSection(root, "英雄改动", heroesHtml); renderPatchesSiteVersion(); } function escapeHtml(s) { return s.replace(/&/g, "&").replace(//g, ">"); } function setPage(page) { if (page !== "heroes" && page !== "items" && page !== "patches") return; closeTalentPopover(); state.page = page; if (page === "heroes") { state.selectedItemKey = null; } else if (page === "items") { state.selectedKey = null; state.inspect = null; } syncStateToUrl(); render(); } function syncChrome() { document.querySelectorAll(".main-tab").forEach((btn) => { btn.classList.toggle("active", btn.dataset.page === state.page); }); const heroesTb = $("#heroes-toolbar"); const itemsTb = $("#items-toolbar"); const heroesView = $("#heroes-view"); const itemsView = $("#items-view"); const patchesView = $("#patches-view"); const detail = $("#detail"); if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes"); if (itemsTb) itemsTb.classList.toggle("hidden", state.page !== "items"); if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes"); if (itemsView) itemsView.classList.toggle("hidden", state.page !== "items"); if (patchesView) patchesView.classList.toggle("hidden", state.page !== "patches"); if (detail) detail.classList.toggle("hidden", state.page !== "heroes"); } function render() { syncChrome(); if (state.page === "heroes") { renderTagbar(); renderBoard(); renderDetail(); } else if (state.page === "items") { renderItemDetail(); renderItemShop(); } else { renderPatches(); } } // Search-box URL sync timers (debounced replace so typing does not spam history). let heroSearchSyncTimer = 0; let itemSearchSyncTimer = 0; const SEARCH_SYNC_DEBOUNCE_MS = 300; /** * Apply a router-produced state patch (from a parsed URL) to state, then render. * Validates every field against loaded data so bad deep-links degrade gracefully * (unknown hero/item/version is dropped rather than crashing the UI). */ function applyPatch(patch) { if (!state.data) { render(); return; } // Page (default heroes on bad/missing). if (patch.page && ["heroes", "items", "patches"].includes(patch.page)) { state.page = patch.page; } else { state.page = "heroes"; } // Hero + detail sub-tab. if (patch.heroKey && heroByKey(patch.heroKey)) { state.selectedKey = patch.heroKey; if ( patch.detailTab && ["skills", "core", "fears", "patches"].includes(patch.detailTab) ) { state.detailTab = patch.detailTab; } else { state.detailTab = "skills"; } } else { state.selectedKey = null; state.detailTab = "skills"; } // Reset inspect so ensureHeroInspectDefault re-picks per tab (inspect is URL-less). state.inspect = null; // Item (items page) — must exist in the shop catalog. if (patch.itemKey && shopItem(patch.itemKey)) { state.selectedItemKey = patch.itemKey; } else { state.selectedItemKey = null; } // Patch version (patches page; null means latest). if ( patch.patchVersion && (state.data.patches || []).some((p) => p.version === patch.patchVersion) ) { state.selectedPatch = patch.patchVersion; } else { state.selectedPatch = null; } // Tag filters (heroes page only) — drop unknown tag names. if (patch.tags instanceof Set) { const valid = new Set(state.data.tag_order || []); state.tagFilters = new Set([...patch.tags].filter((t) => valid.has(t))); } if (typeof patch.query === "string") { state.query = patch.query; const inp = $("#q"); if (inp && inp.value !== state.query) inp.value = state.query; } if (typeof patch.itemQuery === "string") { state.itemQuery = patch.itemQuery; const inp = $("#q-item"); if (inp && inp.value !== state.itemQuery) inp.value = state.itemQuery; } render(); } function bindSearch() { const input = $("#q"); if (input) { input.value = state.query; input.addEventListener("input", () => { state.query = input.value; if (state.page === "heroes") renderBoard(); if (heroSearchSyncTimer) clearTimeout(heroSearchSyncTimer); heroSearchSyncTimer = setTimeout(() => { heroSearchSyncTimer = 0; syncStateToUrl({ replace: true }); }, SEARCH_SYNC_DEBOUNCE_MS); }); } const itemInput = $("#q-item"); if (itemInput) { itemInput.value = state.itemQuery; itemInput.addEventListener("input", () => { state.itemQuery = itemInput.value; if (state.page === "items") renderItemShop(); if (itemSearchSyncTimer) clearTimeout(itemSearchSyncTimer); itemSearchSyncTimer = setTimeout(() => { itemSearchSyncTimer = 0; syncStateToUrl({ replace: true }); }, SEARCH_SYNC_DEBOUNCE_MS); }); } } function bindPageChrome() { document.querySelectorAll(".main-tab").forEach((btn) => { btn.addEventListener("click", () => setPage(btn.dataset.page)); }); } async function main() { document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (talentPopoverEl) { closeTalentPopover(); return; } if (state.page === "heroes") { if (state.query && document.activeElement === $("#q")) { state.query = ""; $("#q").value = ""; syncStateToUrl({ replace: true }); renderBoard(); return; } if (state.selectedKey) { state.selectedKey = null; state.inspect = null; syncStateToUrl(); render(); } return; } if (state.itemQuery && document.activeElement === $("#q-item")) { state.itemQuery = ""; $("#q-item").value = ""; syncStateToUrl({ replace: true }); renderItemShop(); return; } if (state.selectedItemKey) { state.selectedItemKey = null; syncStateToUrl(); render(); } }); document.addEventListener("mousedown", (e) => { if (!talentPopoverEl) return; const t = e.target; if (talentPopoverEl.contains(t)) return; if (t.closest && t.closest(".skill-icon.talent-trigger")) return; closeTalentPopover(); }); window.addEventListener("resize", closeTalentPopover); document.addEventListener( "scroll", (e) => { if (!talentPopoverEl) return; if (e.target === document || e.target === document.documentElement) { closeTalentPopover(); return; } if (e.target && e.target.id === "detail") closeTalentPopover(); }, true ); bindSearch(); bindPageChrome(); const brandLogo = document.querySelector(".brand-logo"); if (brandLogo) brandLogo.src = assetUrl("ui-icon/dota2_logo.png"); const res = await fetch("data.json"); if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`); state.data = await res.json(); state.data.relations = state.data.relations || { counters: [], synergies: [] }; state.data.hero_items = state.data.hero_items || { items: {}, by_hero: {} }; state.data.hero_item_fears = state.data.hero_item_fears || { items: {}, by_hero: {} }; state.data.hero_abilities = state.data.hero_abilities || { by_hero: {} }; state.data.item_shop = state.data.item_shop || { basic: { sections: [] }, upgraded: { sections: [] }, items: {}, }; state.data.items_meta = state.data.items_meta || {}; state.data.patches = state.data.patches || []; state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; state.data.patch_details = state.data.patch_details || {}; _skillEntriesCache.clear(); _resourceBarScaleCache = null; _innateAbilityKeys = null; installRouter({ getState: () => state, applyPatch }); applyUrlToState(); } main().catch((err) => { const el = $("#columns") || $("#item-shop"); if (el) el.textContent = "加载失败: " + err; });