Add STATIC_ASSET_BASE so Cloudflare Pages ships ~2MB HTML/JS/data; icons served from climperor OSS via _oss_static_assets.py. New machines can deploy without local fetch scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
2098 lines
68 KiB
JavaScript
2098 lines
68 KiB
JavaScript
/* 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 = `<div class="col-head"><img class="attr-icon" src="${attrIconSrc(attr)}" 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 = 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 =
|
||
`<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="${attrIconSrc(a.key)}" 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 = 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(/<br\s*\/?>/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 `<img class="stat-icon" src="${attrIconSrc(a)}" 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(/<br\s*\/?>/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 ? innateIconSrc() : abilityIconSrc(akey)}" 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="${portraitSrc(key)}" 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, "&").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;
|
||
});
|