Files
climperor/web/relations/app.js
T
vosonandCursor 124bdbb7c7 v0.4.1: OSS skill videos, site version footer, and mail contact.
Host ability demos on Aliyun OSS instead of Pages bundles; add patches-page version label and top-right mailto link; improve spirit bear portrait export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 20:26:30 +08:00

2062 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* global fetch, document, ABILITY_VIDEO_BASE, SITE_VERSION */
/** OSS / CDN base for ability demos; empty => same-origin `/ability-video/...`. */
function abilityVideoBase() {
const raw =
typeof ABILITY_VIDEO_BASE === "string" ? ABILITY_VIDEO_BASE.trim() : "";
return raw.replace(/\/+$/, "");
}
function abilityVideoUrl(heroKey, abilityKey) {
const rel = `/ability-video/${heroKey}/${abilityKey}.webm`;
const base = abilityVideoBase();
return base ? `${base}${rel}` : rel;
}
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 = `<div class="col-head"><img class="attr-icon" src="attr/${attr}.png" alt="${attr_labels[attr]}" /><span>${attr_labels[attr]}</span></div>`;
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 = `<span class="m-sel" title="选中" aria-label="选中"></span>`;
} 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(`<span class="m-counter" title="克制" aria-label="克制">克</span>`);
}
if (rel.countered) {
btn.classList.add("is-countered");
bits.push(`<span class="m-countered" title="被克制" aria-label="被克制">怕</span>`);
}
if (rel.synergy) {
btn.classList.add("is-synergy");
bits.push(`<span class="m-synergy" title="搭档" aria-label="搭档">搭</span>`);
}
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 = `portrait/${h.key}.png?v=wide`;
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 : [],
};
}
const INNATE_ICON_SRC = "ability/innate.png";
const TALENT_TREE_ICON_SRC = "ability/talent_tree.png";
/** 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 = INNATE_ICON_SRC;
img.onerror = null;
return;
}
img.src = `ability/${encodeURIComponent(abilityKey)}.png`;
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 = TALENT_TREE_ICON_SRC;
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 = "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 =
`<div class="hero-stat-bar-track" aria-hidden="true">` +
`<div class="hero-stat-bar-fill" style="width:${pct.toFixed(1)}%"></div>` +
`</div>` +
`<div class="hero-stat-bar-text">` +
`<span class="hero-stat-bar-label">${label}</span>` +
`<span class="hero-stat-bar-val">${value}` +
(regen ? `<small>${regen}</small>` : "") +
`</span>` +
`</div>`;
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 =
`<img class="attr-icon" src="attr/${a.key}.png" alt="${a.label}" />` +
`<span class="hero-stat-attr-base">${a.base}</span>` +
`<span class="hero-stat-attr-gain">${fmtGain(a.gain)}</span>`;
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 = `item-cat/${encodeURIComponent(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 `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(/<br\s*\/?>/gi, "\n")
.replace(/<\/?h1[^>]*>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/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 `<img class="stat-icon" src="attr/${a}.png" alt="" onerror="this.style.visibility='hidden'">`;
const label = STAT_LABELS[icon] || icon;
return `<span class="stat-chip">${escapeHtml(label)}</span>`;
}
function patchNoteHtml(note) {
const esc = escapeHtml(note == null ? "" : String(note));
return esc.replace(/&lt;br\s*\/?&gt;/gi, "<br/>");
}
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*<br\s*\/?\s*>$/i.test(raw)) {
return `<li class="patch-spacer" style="margin-left:${indent}em"></li>`;
}
return `<li class="hide-dot" style="margin-left:${indent}em">${patchNoteHtml(n.note)}</li>`;
}
return `<li style="margin-left:${indent}em">${patchNoteHtml(n.note)}</li>`;
}).join("");
return `<ul class="patch-notes">${items}</ul>`;
}
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 `<li class="hide-dot" style="margin-left:${indent}em">${chip}${patchNoteHtml(n.note)}</li>`;
}
return `<li style="margin-left:${indent}em">${chip}${patchNoteHtml(n.note)}</li>`;
}).join("");
return `<ul class="patch-notes patch-hero-notes">${items}</ul>`;
}
/** 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
? `<img class="patch-ability-icon" src="${innate ? INNATE_ICON_SRC : "ability/" + encodeURIComponent(akey) + ".png"}" alt="" onerror="this.style.visibility='hidden'">`
: "";
return `<div class="patch-ability">${aicon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(aname)}</span>${renderNotesList(ab.ability_notes)}</div></div>`;
}
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
? `<img class="patch-item-icon" src="${itemIconSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
: "";
return `<div class="patch-entry-row">${icon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(name)}</span>${renderNotesList(entry.ability_notes)}</div></div>`;
}
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
? `<img class="patch-hero-icon" src="portrait/${encodeURIComponent(key)}.png?v=wide" alt="" onerror="this.style.visibility='hidden'">`
: "";
let html = `<div class="patch-hero"><div class="patch-hero-head">${portrait}<span class="patch-name">${escapeHtml(name)}</span></div>`;
html += renderHeroNotes(hero.hero_notes);
for (const ab of (hero.abilities || [])) {
html += patchAbilityBlockHtml(ab, lookup);
}
html += `</div>`;
return html;
}
function renderPatchSection(root, title, html) {
if (!html) return;
const sec = document.createElement("section");
sec.className = "patch-section";
sec.innerHTML = `<h3 class="patch-section-title">${escapeHtml(title)}</h3>${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 = `<div class="patch-change-ver">${escapeHtml(p.version)}<span class="patch-change-date">${escapeHtml(p.date || "")}</span></div>`;
html += renderHeroNotes(hentry.hero_notes);
for (const ab of (hentry.abilities || [])) {
html += patchAbilityBlockHtml(ab, lookup);
}
blocks.push(`<div class="patch-change-version">${html}</div>`);
}
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 = '<div class="patches-empty">暂无版本数据,请运行 python fetch_patches.py</div>';
renderPatchesSiteVersion();
return;
}
ensureDefaultPatch();
if (select) {
select.innerHTML = patches
.map((p) => {
const major = p.website ? " ★" : "";
const sel = p.version === state.selectedPatch ? " selected" : "";
return `<option value="${escapeHtml(p.version)}"${sel}>${escapeHtml(p.version)}${major}${escapeHtml(p.date || "")}</option>`;
})
.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 = '<div class="patches-empty">该版本详情暂未抓取,请重跑 python fetch_patches.py</div>';
renderPatchesSiteVersion();
return;
}
root.innerHTML = "";
const generalHtml = (det.general_notes || [])
.map((g) => {
const t = g.title ? `<h4 class="patch-sub-title">${escapeHtml(g.title)}</h4>` : "";
return `<div class="patch-general">${t}${renderNotesList(g.generic)}</div>`;
})
.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 ? `<h4 class="patch-sub-title">${escapeHtml(e.title)}</h4>` : "";
}
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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 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;
});