/* =============================================
skins.js — 皮肤库
英雄列表:/api/champions → LCU champion-summary.json
英雄头像:/api/heroicon?id= → LCU hero json → skins[0].tilePath
皮肤数据:/api/hero?id= → LCU hero json
图片代理:/api/img?path=
============================================= */
const ROLES = [
{ key: '', label: '全部', icon: '🌐' },
{ key: 'fighter', label: '战士', icon: '⚔️' },
{ key: 'assassin', label: '刺客', icon: '🗡️' },
{ key: 'mage', label: '法师', icon: '🔮' },
{ key: 'marksman', label: '射手', icon: '🏹' },
{ key: 'support', label: '辅助', icon: '💙' },
{ key: 'tank', label: '坦克', icon: '🛡️' },
];
let _champions = [];
let _curRole = '';
let _curQuery = '';
// ── 英雄列表页 ────────────────────────────────
async function renderSkinLib() {
const page = document.getElementById('page-skins');
resetLazyObserver();
if (!APP.summonerId) {
page.innerHTML = '
';
return;
}
page.innerHTML = `
`;
if (_champions.length === 0) await loadChampions();
else applyFilter();
}
async function loadChampions() {
const cached = Cache.get('champions_lcu');
if (cached && cached.length > 0) {
_champions = cached;
applyFilter();
return;
}
try {
// 腾讯接口返回 { hero: [...] }
// 字段:heroId / name / title / alias / keywords / roles[]
const data = await API.get('champions');
const list = Array.isArray(data) ? data : (data.hero || []);
_champions = list.filter(c => (c.id || c.heroId) && parseInt(c.id || c.heroId) > 0);
Cache.set('champions_lcu', _champions);
applyFilter();
} catch(e) {
console.error('[loadChampions]', e);
const g = document.getElementById('champGrid');
if (g) g.innerHTML = '';
}
}
function filterRole(role) {
_curRole = role;
document.querySelectorAll('.role-tab').forEach(el =>
el.classList.toggle('active', el.dataset.role === role));
applyFilter();
}
function filterSearch(q) { _curQuery = q.trim().toLowerCase(); applyFilter(); }
// 模糊搜索:中文名 / 英文名alias / 拼音缩写keywords
function champMatch(c, q) {
if (!q) return true;
const name = (c.name || '').toLowerCase();
const alias = (c.alias || '').toLowerCase();
const keywords = (c.keywords || '').toLowerCase(); // 腾讯接口:拼音全拼+缩写,如 "yasuo,ys,ysjh,疾风剑豪"
const title = (c.title || '').toLowerCase(); // 小名,如 "不败之地"
return name.includes(q) || alias.includes(q) || keywords.includes(q) || title.includes(q);
}
function applyFilter() {
let list = _champions;
if (_curRole) {
list = list.filter(c =>
Array.isArray(c.roles) &&
c.roles.some(r => r.toLowerCase() === _curRole)
);
}
if (_curQuery) {
list = list.filter(c => champMatch(c, _curQuery));
}
const countEl = document.getElementById('champCount');
if (countEl) countEl.innerHTML = `共有 ${list.length} 名英雄`;
renderChampGrid(list);
}
function renderChampGrid(list) {
const grid = document.getElementById('champGrid');
if (!grid) return;
if (list.length === 0) {
grid.innerHTML = '';
return;
}
grid.innerHTML = list.map(c => {
const cid = c.id || c.heroId;
// 有缓存直接写 src,避免切换职业时破图
const cached = Cache.get('tile_' + cid);
const imgAttr = cached
? `src="${esc(API.img(cached))}"`
: `data-champid="${cid}"`;
return `
`;
}).join('');
// 只对还没加载的发请求
const pending = Array.from(grid.querySelectorAll('img[data-champid]'));
if (pending.length > 0) loadAllChampIcons(grid);
}
// 分批并发加载所有英雄头像
function loadAllChampIcons(grid) {
const imgs = Array.from(grid.querySelectorAll('img[data-champid]'));
const BATCH = 8;
let i = 0;
function next() {
const batch = imgs.slice(i, i + BATCH);
if (!batch.length) return;
i += BATCH;
batch.forEach(el => loadOneIcon(el, el.dataset.champid));
if (i < imgs.length) setTimeout(next, 80);
}
next();
}
async function loadOneIcon(el, id) {
try {
let tp = Cache.get('tile_' + id);
if (!tp) {
// 优先从已缓存的 hero json 取
const hero = Cache.get('hero_' + id);
if (hero && hero.skins && hero.skins[0]) {
tp = hero.skins[0].tilePath;
} else {
const data = await API.get('heroicon', { id });
if (data && data.skins && data.skins[0]) {
tp = data.skins[0].tilePath;
// 顺便把 hero json 也缓存了,打开皮肤时不用再请求
Cache.set('hero_' + id, data);
}
}
if (tp) Cache.set('tile_' + id, tp);
}
if (tp && el.isConnected) {
el.src = API.img(tp);
el.style.minHeight = '';
el.style.background = '';
}
} catch(e) { /* 静默失败 */ }
}
// ── 皮肤详情 ─────────────────────────────────
let _skins = [], _curChampId = 0, _curChampName = '', _curChampAlias = '';
let _activeSkinIdx = 0, _activeChromaIdx = -1, _thumbOffset = 0;
async function openChampSkins(champId, champName, champAlias) {
const page = document.getElementById('page-skins');
page.innerHTML = '加载皮肤
';
// 先确定 alias:优先用传入的,其次从缓存的 hero 里取
let hero = Cache.get('hero_' + champId);
const alias = champAlias || (hero && hero.alias) || '';
// 并发:英雄皮肤数据 + xc炫彩数据(直接读本地 xc/xc.txt,不依赖后端新接口)
const [heroResult, xcResult] = await Promise.allSettled([
hero ? Promise.resolve(hero) : API.get('hero', { id: champId }),
_loadXcData(alias),
]);
if (heroResult.status === 'rejected') {
page.innerHTML = '';
return;
}
hero = heroResult.value;
Cache.set('hero_' + champId, hero);
// alias 再补一次 hero.alias(应对自动跳转时 champAlias 为空的情况)
const finalAlias = alias || hero.alias || '';
const xcData = xcResult.status === 'fulfilled' ? xcResult.value : [];
// 若首次 alias 为空导致 xcData 为空,用 hero.alias 补一次
const xcDataFinal = (xcData.length === 0 && finalAlias && finalAlias !== alias)
? await _loadXcData(finalAlias)
: xcData;
_skins = buildSkinList(hero, finalAlias, xcDataFinal);
_curChampId = champId;
_curChampName = champName;
_curChampAlias = finalAlias;
_activeSkinIdx = 0;
_activeChromaIdx = -1;
_thumbOffset = 0;
renderSkinDetail(page);
}
// 从内置 XC_DATA 读取指定英雄的炫彩条目(不依赖任何网络/文件接口)
// 从 XC_DATA 读取指定英雄的炫彩数据
// 返回 Map
// skinIdBase = 皮肤ID末尾3位,精确对应 xcdata.js 里的 skinId
function _loadXcData(alias) {
if (!alias || typeof XC_DATA === 'undefined') return Promise.resolve(new Map());
const key = Object.keys(XC_DATA).find(k => k.toLowerCase() === alias.toLowerCase());
if (!key) return Promise.resolve(new Map());
const list = XC_DATA[key]; // 数组格式 [{skinId, chromas:[]}]
const result = new Map();
if (Array.isArray(list)) {
// 新格式:[{ skinId, chromas: [{file, name, color}] }]
list.forEach(entry => {
if (entry.skinId != null && Array.isArray(entry.chromas)) {
result.set(entry.skinId, entry.chromas);
}
});
}
return Promise.resolve(result);
}
function buildSkinList(hero, alias, xcMap) {
const skins = hero.skins || [];
const result = [];
const hallIdx = [];
skins.forEach((s, i) => {
// LCU 原生炫彩
const chromas = (s.chromas || []).map(ch => ({
id: ch.id,
name: ch.name || '',
chromaPath: ch.chromaPath || '',
color: (ch.colors && ch.colors[0]) || '#999999',
isXc: false,
}));
// 注入 xc 本地炫彩:用皮肤ID末尾3位精确匹配 xcMap 的 key
// 默认皮肤(isBase 或 id末尾=0)跳过
const base = s.id % 1000;
if (chromas.length === 0 && xcMap.size > 0 && !s.isBase && base !== 0) {
const xcChromas = xcMap.get(base);
if (xcChromas && xcChromas.length > 0) {
xcChromas.forEach((xc, idx) => {
// 从文件名提取炫彩ID,如 Viego994.png → 994
const xcId = parseInt((xc.file || '').replace(/[^0-9]/g, '')) || 0;
chromas.push({
id: xcId,
name: xc.name || `炫彩${idx + 1}`,
chromaPath: '',
xcFile: xc.file || '',
color: xc.color || '#999999',
isXc: true,
});
});
}
}
result.push({
id: s.id,
name: s.name || '',
splashPath: s.splashPath || '',
tilePath: s.tilePath || '',
description: s.description || '',
shortBio: hero.shortBio || '',
isBase: s.isBase || false,
hasChroma: chromas.length > 0,
chromas,
});
if ((s.name || '').includes('殿堂传奇')) hallIdx.push(i);
});
hallIdx.forEach(i => {
const qi = skins[i];
const tiers = qi?.questSkinInfo?.tiers;
if (!tiers || tiers.length < 2) return;
const t = tiers[1];
const immortalName = t.name || `联盟不朽 ${hero.name || _curChampName || ''}`.trim();
// 联盟不朽皮肤的 xc 炫彩注入
const immortalChromas = [];
if (xcMap.size > 0 && t.id) {
const base = t.id % 1000;
const xcChromas = xcMap.get(base);
if (xcChromas && xcChromas.length > 0) {
xcChromas.forEach((xc, idx) => {
const xcId = parseInt((xc.file || '').replace(/[^0-9]/g, '')) || 0;
immortalChromas.push({
id: xcId,
name: xc.name || `炫彩${idx + 1}`,
chromaPath: '',
xcFile: xc.file || '',
color: xc.color || '#999999',
isXc: true,
});
});
}
}
result.push({
id: t.id || qi.id,
name: immortalName,
splashPath: t.splashPath || '',
tilePath: t.tilePath || '',
description: t.description || '',
shortBio: hero.shortBio || '',
isBase: false,
hasChroma: immortalChromas.length > 0,
chromas: immortalChromas,
});
});
return result;
}
function renderSkinDetail(page) {
const tileCached = Cache.get('tile_' + _curChampId);
const iconSrc = tileCached ? API.img(tileCached)
: (_skins[0] && _skins[0].tilePath ? API.img(_skins[0].tilePath) : '');
page.innerHTML = `
${esc(_curChampName)}
共 ${_skins.length} 个皮肤
`;
updateSplash();
renderSkinGrid();
}
function updateSplash() {
const skin = _skins[_activeSkinIdx];
if (!skin) return;
const bg = document.getElementById('spBgImg');
if (bg && skin.splashPath) {
bg.style.opacity = '0';
const newSrc = API.img(skin.splashPath);
setTimeout(() => {
bg.src = newSrc;
bg.onload = () => { bg.style.opacity = '1'; };
bg.onerror = () => { bg.style.opacity = '1'; };
}, 200);
}
const nameEl = document.getElementById('spSkinName');
const descEl = document.getElementById('spSkinDesc');
if (nameEl) nameEl.textContent = skin.name;
if (descEl) descEl.textContent = skin.description || skin.shortBio || '';
const chromaWrap = document.getElementById('spChromaWrap');
const chromaRow = document.getElementById('spChromaRow');
if (!chromaWrap || !chromaRow) return;
if (skin.hasChroma && skin.chromas.length > 0) {
const wrap = document.getElementById('spChromaWrap');
const row = document.getElementById('spChromaRow');
const canvas = document.getElementById('spCanvas');
// fixed 定位到 canvas 右上角
const rect = canvas.getBoundingClientRect();
wrap.style.top = (rect.top + 12) + 'px';
wrap.style.right = (window.innerWidth - rect.right + 12) + 'px';
// 先重置到右边屏幕外,再 display:flex
wrap.style.transform = 'translateX(60px)';
wrap.style.opacity = '0';
wrap.style.transition = 'none';
wrap.style.display = 'flex';
row.innerHTML = skin.chromas.map((ch, ci) => {
// xc 炫彩用 color 字段显示圆点颜色,LCU 原生炫彩也用 color
const dotStyle = ch.isXc
? `background:${esc(ch.color)};border-color:rgba(255,255,255,0.5);`
: `background:${esc(ch.color)};`;
return `
`;
}).join('');
// 预填炫彩内容
const cardImg = document.getElementById('spChromaCardImg');
const cardName = document.getElementById('spChromaCardName');
const card = document.getElementById('spChromaCard');
const firstCh = skin.chromas[0];
if (cardImg && firstCh) {
cardImg.src = firstCh.isXc && firstCh.xcFile
? `xc/${encodeURIComponent(firstCh.xcFile)}`
: (firstCh.chromaPath ? API.img(firstCh.chromaPath) : '');
}
if (cardName) cardName.textContent = firstCh?.name || '';
if (card) card.style.display = 'none';
_activeChromaIdx = -1;
// 两帧后触发从右边弹入动画
requestAnimationFrame(() => requestAnimationFrame(() => {
wrap.style.transition = 'transform 0.45s cubic-bezier(.34,1.56,.64,1), opacity 0.3s ease';
wrap.style.transform = 'translateX(0)';
wrap.style.opacity = '1';
}));
} else {
const wrap = document.getElementById('spChromaWrap');
// 向右滑出
wrap.style.transition = 'transform 0.25s ease, opacity 0.2s ease';
wrap.style.transform = 'translateX(60px)';
wrap.style.opacity = '0';
setTimeout(() => { wrap.style.display = 'none'; }, 260);
_activeChromaIdx = -1;
}
}
// showChromaCard、chromaWrapEnter、chromaWrapLeave 已废弃,炫彩改用hover事件直接处理
function hoverChroma(skinIdx, ci) {
const ch = _skins[skinIdx]?.chromas?.[ci];
if (!ch) return;
// 圆点放大高亮(只是视觉,不改变 _activeChromaIdx)
const dots = document.querySelectorAll('#spChromaRow span');
dots.forEach((el, i) => {
el.style.transform = i === ci ? 'scale(1.35)' : 'scale(1)';
el.style.borderColor = i === ci ? '#fff' : 'rgba(255,255,255,0.3)';
el.style.transition = 'transform .15s, border-color .15s';
});
// 更新卡片内容
const card = document.getElementById('spChromaCard');
const img = document.getElementById('spChromaCardImg');
const name = document.getElementById('spChromaCardName');
if (img) {
img.src = ch.isXc && ch.xcFile
? `xc/${encodeURIComponent(ch.xcFile)}`
: (ch.chromaPath ? API.img(ch.chromaPath) : '');
}
if (name) name.textContent = ch.name || '';
// 显示卡片
if (card && card.style.display === 'none') {
card.style.transition = 'none';
card.style.transform = 'translateY(-10px) scale(0.88)';
card.style.opacity = '0';
card.style.display = 'block';
requestAnimationFrame(() => requestAnimationFrame(() => {
card.style.transition = 'transform 0.32s cubic-bezier(.34,1.56,.64,1), opacity 0.22s ease';
card.style.transform = 'translateY(0) scale(1)';
card.style.opacity = '1';
}));
} else if (card) {
card.style.transform = 'translateY(0) scale(1)';
card.style.opacity = '1';
}
}
function leaveChroma(ci) {
// 还原圆点样式
const dots = document.querySelectorAll('#spChromaRow span');
dots.forEach((el, i) => {
// 如果有选中的炫彩,保持选中态高亮
const isActive = _activeChromaIdx >= 0 && i === _activeChromaIdx;
el.style.transform = isActive ? 'scale(1.2)' : 'scale(1)';
el.style.borderColor = isActive ? '#fff' : 'rgba(255,255,255,0.3)';
});
// 如果没有选中任何炫彩,隐藏卡片
if (_activeChromaIdx < 0) {
const card = document.getElementById('spChromaCard');
if (card) {
card.style.transition = 'transform 0.2s ease, opacity 0.18s ease';
card.style.transform = 'translateY(-8px) scale(0.92)';
card.style.opacity = '0';
setTimeout(() => { if (card.style.opacity === '0') card.style.display = 'none'; }, 200);
}
}
}
function selectChroma(skinIdx, ci) {
const ch = _skins[skinIdx]?.chromas?.[ci];
if (!ch) return;
// 点击同一个已选中的 → 取消选中
if (_activeChromaIdx === ci) {
_activeChromaIdx = -1;
const dots = document.querySelectorAll('#spChromaRow span');
dots.forEach(el => { el.style.borderColor = 'rgba(255,255,255,0.3)'; el.style.transform = 'scale(1)'; });
return;
}
// 选中新的炫彩
_activeChromaIdx = ci;
hoverChroma(skinIdx, ci);
}
// renderThumbs、scrollThumbToActive、slideThumb 已废弃,皮肤详情改用网格布局
// 鼠标拖拽滑动
function initThumbDrag(track) {
let startX = 0, startOffset = 0, dragging = false;
track.addEventListener('mousedown', e => {
dragging = true;
startX = e.clientX;
startOffset = _thumbOffset;
track.classList.add('dragging');
e.preventDefault();
});
// 绑定在 track 容器上而非 document,避免全局泄漏
const onMove = e => {
if (!dragging) return;
const scroll = document.getElementById('spThumbScroll');
const maxOffset = Math.max(0, track.scrollWidth - (scroll?.offsetWidth || 0));
_thumbOffset = Math.max(0, Math.min(startOffset - (e.clientX - startX), maxOffset));
track.style.transform = `translateX(-${_thumbOffset}px)`;
};
const onUp = () => {
if (dragging) { dragging = false; track.classList.remove('dragging'); }
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
// 皮肤详情页销毁时自动解绑
track._cleanDrag = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}
function scrollThumbToActive() {} // 已废弃
function slideThumb() {} // 已废弃
function selectSkin(idx) {
_activeSkinIdx = idx; _activeChromaIdx = -1;
updateSplash();
document.querySelectorAll('#spSkinGrid .sp-thumb').forEach((el, i) =>
el.classList.toggle('active', i === idx));
const active = document.querySelector('#spSkinGrid .sp-thumb.active');
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function prevSkin() { if (_skins.length) selectSkin((_activeSkinIdx - 1 + _skins.length) % _skins.length); }
function nextSkin() { if (_skins.length) selectSkin((_activeSkinIdx + 1) % _skins.length); }
async function equipCurrentSkin() {
const skin = _skins[_activeSkinIdx];
if (!skin) return;
if (skin.isBase || _activeSkinIdx === 0) {
showToast(`应用:"${skin.name}" 默认皮肤失败!`, 'err');
return;
}
let applyId = skin.id;
let applyName = skin.name;
if (_activeChromaIdx >= 0 && skin.chromas[_activeChromaIdx]) {
const ch = skin.chromas[_activeChromaIdx];
applyName = ch.name || skin.name;
// xc 炫彩和 LCU 原生炫彩都直接用 ch.id 传给后端
// xc 炫彩的 ch.id 就是文件名数字(如 Viego994.png → 994)
applyId = ch.id;
}
_chromaLocked = false;
const card = document.getElementById('spChromaCard');
if (card) card.style.display = 'none';
showLoadingToast('正在应用皮肤...');
try {
await API.get('applySkin', { skinId: applyId, heroId: _curChampId, alias: _curChampAlias });
hideLoadingToast();
showToast(`已应用皮肤:${applyName}`, 'ok');
} catch(e) {
hideLoadingToast();
showToast('请在英雄选择阶段使用此功能', 'err');
}
}
async function setWallpaper() {
const skin = _skins[_activeSkinIdx];
if (!skin) return;
try {
await API.get('setCareerBg', { skinId: skin.id, heroId: _curChampId, alias: _curChampAlias });
showToast('✓ 生涯壁纸已更新', 'ok');
} catch(e) { showToast('设置壁纸失败', 'err'); }
}
// chromaWrapEnter/Leave 已废弃
let _chromaLocked = false;
// ── 皮肤图标网格(替代横向滑动条)────────────
function renderSkinGrid() {
const grid = document.getElementById('spSkinGrid');
if (!grid) return;
grid.innerHTML = _skins.map((skin, idx) => {
let corner = '';
if (skin.hasChroma && skin.chromas.length > 0)
corner = `${skin.chromas.length}
`;
else if (skin.name.includes('联盟不朽'))
corner = `不朽
`;
return `
![${esc(skin.name)}]()
${corner}
${esc(skin.name)}
`;
}).join('');
observeLazy(grid);
}